From 6d1f80557303a9a2981a7fe0e4977ba6b6486bd9 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:26:02 -0400 Subject: [PATCH 01/28] feat: add 30 research API endpoints, 28 MCP tools, Zod validation, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research primitive — provider-agnostic music industry research: - 30 REST endpoints under /api/research/ (Chartmetric, Perplexity, Exa, Parallel) - 28 MCP tools with proper auth (resolveAccountId) and credit deduction - 2 shared handlers (handleArtistResearch, handleResearchRequest) for DRY - Zod validation on discover endpoint - 10 test files (token, proxy, artist resolution, charts, lookup, similar, search, track, web, discover) - Source param allowlist on metrics to prevent path injection - proxyToChartmetric wrapped in try/catch for consistent error contract - All 1767 tests passing, 0 lint errors in research files Made-with: Cursor --- app/api/research/albums/route.ts | 22 +++ app/api/research/audience/route.ts | 22 +++ app/api/research/career/route.ts | 22 +++ app/api/research/charts/route.ts | 22 +++ app/api/research/cities/route.ts | 22 +++ app/api/research/curator/route.ts | 22 +++ app/api/research/deep/route.ts | 22 +++ app/api/research/discover/route.ts | 22 +++ app/api/research/enrich/route.ts | 22 +++ app/api/research/extract/route.ts | 22 +++ app/api/research/festivals/route.ts | 22 +++ app/api/research/genres/route.ts | 22 +++ app/api/research/insights/route.ts | 22 +++ app/api/research/instagram-posts/route.ts | 22 +++ app/api/research/lookup/route.ts | 22 +++ app/api/research/metrics/route.ts | 22 +++ app/api/research/milestones/route.ts | 22 +++ app/api/research/people/route.ts | 22 +++ app/api/research/playlist/route.ts | 22 +++ app/api/research/playlists/route.ts | 22 +++ app/api/research/profile/route.ts | 22 +++ app/api/research/radio/route.ts | 22 +++ app/api/research/rank/route.ts | 22 +++ app/api/research/route.ts | 22 +++ app/api/research/similar/route.ts | 22 +++ app/api/research/track/route.ts | 22 +++ app/api/research/tracks/route.ts | 22 +++ app/api/research/urls/route.ts | 22 +++ app/api/research/venues/route.ts | 22 +++ app/api/research/web/route.ts | 22 +++ .../__tests__/getChartmetricToken.test.ts | 77 ++++++++++ lib/chartmetric/getChartmetricToken.ts | 60 ++++++++ lib/exa/searchPeople.ts | 66 +++++++++ lib/mcp/tools/index.ts | 2 + lib/mcp/tools/research/index.ts | 64 ++++++++ .../research/registerResearchAlbumsTool.ts | 67 +++++++++ .../research/registerResearchArtistTool.ts | 66 +++++++++ .../research/registerResearchAudienceTool.ts | 73 +++++++++ .../research/registerResearchCareerTool.ts | 64 ++++++++ .../research/registerResearchChartsTool.ts | 79 ++++++++++ .../research/registerResearchCitiesTool.ts | 81 ++++++++++ .../research/registerResearchCuratorTool.ts | 66 +++++++++ .../research/registerResearchDiscoverTool.ts | 79 ++++++++++ .../research/registerResearchEnrichTool.ts | 69 +++++++++ .../research/registerResearchExtractTool.ts | 61 ++++++++ .../research/registerResearchFestivalsTool.ts | 54 +++++++ .../research/registerResearchGenresTool.ts | 55 +++++++ .../research/registerResearchInsightsTool.ts | 67 +++++++++ .../registerResearchInstagramPostsTool.ts | 65 +++++++++ .../research/registerResearchLookupTool.ts | 62 ++++++++ .../research/registerResearchMetricsTool.ts | 71 +++++++++ .../registerResearchMilestonesTool.ts | 66 +++++++++ .../research/registerResearchPeopleTool.ts | 60 ++++++++ .../research/registerResearchPlaylistTool.ts | 88 +++++++++++ .../research/registerResearchPlaylistsTool.ts | 110 ++++++++++++++ .../research/registerResearchRadioTool.ts | 57 ++++++++ .../research/registerResearchRankTool.ts | 63 ++++++++ .../research/registerResearchSearchTool.ts | 71 +++++++++ .../research/registerResearchSimilarTool.ts | 94 ++++++++++++ .../research/registerResearchTrackTool.ts | 70 +++++++++ .../research/registerResearchTracksTool.ts | 67 +++++++++ .../research/registerResearchUrlsTool.ts | 62 ++++++++ .../research/registerResearchVenuesTool.ts | 66 +++++++++ lib/parallel/enrichEntity.ts | 95 ++++++++++++ lib/parallel/extractUrl.ts | 60 ++++++++ .../getResearchChartsHandler.test.ts | 83 +++++++++++ .../getResearchDiscoverHandler.test.ts | 138 ++++++++++++++++++ .../getResearchLookupHandler.test.ts | 91 ++++++++++++ .../getResearchMetricsHandler.test.ts | 81 ++++++++++ .../getResearchSearchHandler.test.ts | 71 +++++++++ .../getResearchSimilarHandler.test.ts | 87 +++++++++++ .../__tests__/getResearchTrackHandler.test.ts | 76 ++++++++++ .../__tests__/postResearchWebHandler.test.ts | 83 +++++++++++ .../__tests__/proxyToChartmetric.test.ts | 93 ++++++++++++ lib/research/__tests__/resolveArtist.test.ts | 81 ++++++++++ lib/research/getResearchAlbumsHandler.ts | 20 +++ lib/research/getResearchAudienceHandler.ts | 19 +++ lib/research/getResearchCareerHandler.ts | 20 +++ lib/research/getResearchChartsHandler.ts | 44 ++++++ lib/research/getResearchCitiesHandler.ts | 33 +++++ lib/research/getResearchCuratorHandler.ts | 26 ++++ lib/research/getResearchDiscoverHandler.ts | 42 ++++++ lib/research/getResearchFestivalsHandler.ts | 19 +++ lib/research/getResearchGenresHandler.ts | 19 +++ lib/research/getResearchInsightsHandler.ts | 21 +++ .../getResearchInstagramPostsHandler.ts | 16 ++ lib/research/getResearchLookupHandler.ts | 70 +++++++++ lib/research/getResearchMetricsHandler.ts | 51 +++++++ lib/research/getResearchMilestonesHandler.ts | 20 +++ lib/research/getResearchPlaylistHandler.ts | 91 ++++++++++++ lib/research/getResearchPlaylistsHandler.ts | 61 ++++++++ lib/research/getResearchProfileHandler.ts | 15 ++ lib/research/getResearchRadioHandler.ts | 19 +++ lib/research/getResearchRankHandler.ts | 19 +++ lib/research/getResearchSearchHandler.ts | 52 +++++++ lib/research/getResearchSimilarHandler.ts | 36 +++++ lib/research/getResearchTrackHandler.ts | 76 ++++++++++ lib/research/getResearchTracksHandler.ts | 20 +++ lib/research/getResearchUrlsHandler.ts | 28 ++++ lib/research/getResearchVenuesHandler.ts | 19 +++ lib/research/handleArtistResearch.ts | 73 +++++++++ lib/research/handleResearchRequest.ts | 57 ++++++++ lib/research/postResearchDeepHandler.ts | 63 ++++++++ lib/research/postResearchEnrichHandler.ts | 79 ++++++++++ lib/research/postResearchExtractHandler.ts | 65 +++++++++ lib/research/postResearchPeopleHandler.ts | 64 ++++++++ lib/research/postResearchWebHandler.ts | 70 +++++++++ lib/research/proxyToChartmetric.ts | 57 ++++++++ lib/research/resolveArtist.ts | 53 +++++++ lib/research/validateDiscoverQuery.ts | 47 ++++++ 110 files changed, 5405 insertions(+) create mode 100644 app/api/research/albums/route.ts create mode 100644 app/api/research/audience/route.ts create mode 100644 app/api/research/career/route.ts create mode 100644 app/api/research/charts/route.ts create mode 100644 app/api/research/cities/route.ts create mode 100644 app/api/research/curator/route.ts create mode 100644 app/api/research/deep/route.ts create mode 100644 app/api/research/discover/route.ts create mode 100644 app/api/research/enrich/route.ts create mode 100644 app/api/research/extract/route.ts create mode 100644 app/api/research/festivals/route.ts create mode 100644 app/api/research/genres/route.ts create mode 100644 app/api/research/insights/route.ts create mode 100644 app/api/research/instagram-posts/route.ts create mode 100644 app/api/research/lookup/route.ts create mode 100644 app/api/research/metrics/route.ts create mode 100644 app/api/research/milestones/route.ts create mode 100644 app/api/research/people/route.ts create mode 100644 app/api/research/playlist/route.ts create mode 100644 app/api/research/playlists/route.ts create mode 100644 app/api/research/profile/route.ts create mode 100644 app/api/research/radio/route.ts create mode 100644 app/api/research/rank/route.ts create mode 100644 app/api/research/route.ts create mode 100644 app/api/research/similar/route.ts create mode 100644 app/api/research/track/route.ts create mode 100644 app/api/research/tracks/route.ts create mode 100644 app/api/research/urls/route.ts create mode 100644 app/api/research/venues/route.ts create mode 100644 app/api/research/web/route.ts create mode 100644 lib/chartmetric/__tests__/getChartmetricToken.test.ts create mode 100644 lib/chartmetric/getChartmetricToken.ts create mode 100644 lib/exa/searchPeople.ts create mode 100644 lib/mcp/tools/research/index.ts create mode 100644 lib/mcp/tools/research/registerResearchAlbumsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchArtistTool.ts create mode 100644 lib/mcp/tools/research/registerResearchAudienceTool.ts create mode 100644 lib/mcp/tools/research/registerResearchCareerTool.ts create mode 100644 lib/mcp/tools/research/registerResearchChartsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchCitiesTool.ts create mode 100644 lib/mcp/tools/research/registerResearchCuratorTool.ts create mode 100644 lib/mcp/tools/research/registerResearchDiscoverTool.ts create mode 100644 lib/mcp/tools/research/registerResearchEnrichTool.ts create mode 100644 lib/mcp/tools/research/registerResearchExtractTool.ts create mode 100644 lib/mcp/tools/research/registerResearchFestivalsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchGenresTool.ts create mode 100644 lib/mcp/tools/research/registerResearchInsightsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchInstagramPostsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchLookupTool.ts create mode 100644 lib/mcp/tools/research/registerResearchMetricsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchMilestonesTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPeopleTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPlaylistTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPlaylistsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchRadioTool.ts create mode 100644 lib/mcp/tools/research/registerResearchRankTool.ts create mode 100644 lib/mcp/tools/research/registerResearchSearchTool.ts create mode 100644 lib/mcp/tools/research/registerResearchSimilarTool.ts create mode 100644 lib/mcp/tools/research/registerResearchTrackTool.ts create mode 100644 lib/mcp/tools/research/registerResearchTracksTool.ts create mode 100644 lib/mcp/tools/research/registerResearchUrlsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchVenuesTool.ts create mode 100644 lib/parallel/enrichEntity.ts create mode 100644 lib/parallel/extractUrl.ts create mode 100644 lib/research/__tests__/getResearchChartsHandler.test.ts create mode 100644 lib/research/__tests__/getResearchDiscoverHandler.test.ts create mode 100644 lib/research/__tests__/getResearchLookupHandler.test.ts create mode 100644 lib/research/__tests__/getResearchMetricsHandler.test.ts create mode 100644 lib/research/__tests__/getResearchSearchHandler.test.ts create mode 100644 lib/research/__tests__/getResearchSimilarHandler.test.ts create mode 100644 lib/research/__tests__/getResearchTrackHandler.test.ts create mode 100644 lib/research/__tests__/postResearchWebHandler.test.ts create mode 100644 lib/research/__tests__/proxyToChartmetric.test.ts create mode 100644 lib/research/__tests__/resolveArtist.test.ts create mode 100644 lib/research/getResearchAlbumsHandler.ts create mode 100644 lib/research/getResearchAudienceHandler.ts create mode 100644 lib/research/getResearchCareerHandler.ts create mode 100644 lib/research/getResearchChartsHandler.ts create mode 100644 lib/research/getResearchCitiesHandler.ts create mode 100644 lib/research/getResearchCuratorHandler.ts create mode 100644 lib/research/getResearchDiscoverHandler.ts create mode 100644 lib/research/getResearchFestivalsHandler.ts create mode 100644 lib/research/getResearchGenresHandler.ts create mode 100644 lib/research/getResearchInsightsHandler.ts create mode 100644 lib/research/getResearchInstagramPostsHandler.ts create mode 100644 lib/research/getResearchLookupHandler.ts create mode 100644 lib/research/getResearchMetricsHandler.ts create mode 100644 lib/research/getResearchMilestonesHandler.ts create mode 100644 lib/research/getResearchPlaylistHandler.ts create mode 100644 lib/research/getResearchPlaylistsHandler.ts create mode 100644 lib/research/getResearchProfileHandler.ts create mode 100644 lib/research/getResearchRadioHandler.ts create mode 100644 lib/research/getResearchRankHandler.ts create mode 100644 lib/research/getResearchSearchHandler.ts create mode 100644 lib/research/getResearchSimilarHandler.ts create mode 100644 lib/research/getResearchTrackHandler.ts create mode 100644 lib/research/getResearchTracksHandler.ts create mode 100644 lib/research/getResearchUrlsHandler.ts create mode 100644 lib/research/getResearchVenuesHandler.ts create mode 100644 lib/research/handleArtistResearch.ts create mode 100644 lib/research/handleResearchRequest.ts create mode 100644 lib/research/postResearchDeepHandler.ts create mode 100644 lib/research/postResearchEnrichHandler.ts create mode 100644 lib/research/postResearchExtractHandler.ts create mode 100644 lib/research/postResearchPeopleHandler.ts create mode 100644 lib/research/postResearchWebHandler.ts create mode 100644 lib/research/proxyToChartmetric.ts create mode 100644 lib/research/resolveArtist.ts create mode 100644 lib/research/validateDiscoverQuery.ts diff --git a/app/api/research/albums/route.ts b/app/api/research/albums/route.ts new file mode 100644 index 000000000..a5c539d47 --- /dev/null +++ b/app/api/research/albums/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAlbumsHandler } from "@/lib/research/getResearchAlbumsHandler"; + +/** + * OPTIONS /api/research/albums — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/albums — Artist album discography with release dates. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON album list or error + */ +export async function GET(request: NextRequest) { + return getResearchAlbumsHandler(request); +} diff --git a/app/api/research/audience/route.ts b/app/api/research/audience/route.ts new file mode 100644 index 000000000..34cf9a00e --- /dev/null +++ b/app/api/research/audience/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAudienceHandler } from "@/lib/research/getResearchAudienceHandler"; + +/** + * OPTIONS /api/research/audience — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/audience — Audience demographics by platform. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON audience demographics or error + */ +export async function GET(request: NextRequest) { + return getResearchAudienceHandler(request); +} diff --git a/app/api/research/career/route.ts b/app/api/research/career/route.ts new file mode 100644 index 000000000..9844ddff3 --- /dev/null +++ b/app/api/research/career/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCareerHandler } from "@/lib/research/getResearchCareerHandler"; + +/** + * OPTIONS /api/research/career — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/career — Artist career history and milestones. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON career timeline or error + */ +export async function GET(request: NextRequest) { + return getResearchCareerHandler(request); +} diff --git a/app/api/research/charts/route.ts b/app/api/research/charts/route.ts new file mode 100644 index 000000000..c92c5b2f5 --- /dev/null +++ b/app/api/research/charts/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchChartsHandler } from "@/lib/research/getResearchChartsHandler"; + +/** + * OPTIONS /api/research/charts — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/charts — Global chart positions by platform and country. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON chart positions or error + */ +export async function GET(request: NextRequest) { + return getResearchChartsHandler(request); +} diff --git a/app/api/research/cities/route.ts b/app/api/research/cities/route.ts new file mode 100644 index 000000000..28920d381 --- /dev/null +++ b/app/api/research/cities/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCitiesHandler } from "@/lib/research/getResearchCitiesHandler"; + +/** + * OPTIONS /api/research/cities — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/cities — Geographic listening data for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON city-level listener data or error + */ +export async function GET(request: NextRequest) { + return getResearchCitiesHandler(request); +} diff --git a/app/api/research/curator/route.ts b/app/api/research/curator/route.ts new file mode 100644 index 000000000..6f3c7668d --- /dev/null +++ b/app/api/research/curator/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCuratorHandler } from "@/lib/research/getResearchCuratorHandler"; + +/** + * OPTIONS /api/research/curator — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/curator — Playlist curator details. Requires `?platform=` and `?id=` query params. + * + * @param request - must include `platform` and `id` query params + * @returns JSON curator profile or error + */ +export async function GET(request: NextRequest) { + return getResearchCuratorHandler(request); +} diff --git a/app/api/research/deep/route.ts b/app/api/research/deep/route.ts new file mode 100644 index 000000000..1b8b9ff3c --- /dev/null +++ b/app/api/research/deep/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchDeepHandler } from "@/lib/research/postResearchDeepHandler"; + +/** + * OPTIONS /api/research/deep — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/deep — Deep, comprehensive research with citations. Body: `{ query }`. + * + * @param request - JSON body with `query` string + * @returns JSON research report with citations or error + */ +export async function POST(request: NextRequest) { + return postResearchDeepHandler(request); +} diff --git a/app/api/research/discover/route.ts b/app/api/research/discover/route.ts new file mode 100644 index 000000000..836a0a8a9 --- /dev/null +++ b/app/api/research/discover/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchDiscoverHandler } from "@/lib/research/getResearchDiscoverHandler"; + +/** + * OPTIONS /api/research/discover — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/discover — Discover artists by genre, country, and growth criteria. Supports `?genre=`, `?country=`, `?sort=`, `?limit=` filters. + * + * @param request - filter criteria via query params + * @returns JSON array of matching artists or error + */ +export async function GET(request: NextRequest) { + return getResearchDiscoverHandler(request); +} diff --git a/app/api/research/enrich/route.ts b/app/api/research/enrich/route.ts new file mode 100644 index 000000000..46cba9a27 --- /dev/null +++ b/app/api/research/enrich/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchEnrichHandler } from "@/lib/research/postResearchEnrichHandler"; + +/** + * OPTIONS /api/research/enrich — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/enrich — Enrich an entity with structured web research data. Body: `{ url, prompt? }`. + * + * @param request - JSON body with `url` and optional `prompt` + * @returns JSON enriched entity data or error + */ +export async function POST(request: NextRequest) { + return postResearchEnrichHandler(request); +} diff --git a/app/api/research/extract/route.ts b/app/api/research/extract/route.ts new file mode 100644 index 000000000..a9f0c795c --- /dev/null +++ b/app/api/research/extract/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchExtractHandler } from "@/lib/research/postResearchExtractHandler"; + +/** + * OPTIONS /api/research/extract — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/extract — Extract clean markdown from URLs. Body: `{ urls }`. + * + * @param request - JSON body with `urls` array + * @returns JSON extracted markdown content or error + */ +export async function POST(request: NextRequest) { + return postResearchExtractHandler(request); +} diff --git a/app/api/research/festivals/route.ts b/app/api/research/festivals/route.ts new file mode 100644 index 000000000..6cd15d3c7 --- /dev/null +++ b/app/api/research/festivals/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchFestivalsHandler } from "@/lib/research/getResearchFestivalsHandler"; + +/** + * OPTIONS /api/research/festivals — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/festivals — List of music festivals. + * + * @param request - optional filter query params + * @returns JSON festival list or error + */ +export async function GET(request: NextRequest) { + return getResearchFestivalsHandler(request); +} diff --git a/app/api/research/genres/route.ts b/app/api/research/genres/route.ts new file mode 100644 index 000000000..c8a665ba1 --- /dev/null +++ b/app/api/research/genres/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchGenresHandler } from "@/lib/research/getResearchGenresHandler"; + +/** + * OPTIONS /api/research/genres — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/genres — All available genre IDs and names. + * + * @param request - no required query params + * @returns JSON genre list or error + */ +export async function GET(request: NextRequest) { + return getResearchGenresHandler(request); +} diff --git a/app/api/research/insights/route.ts b/app/api/research/insights/route.ts new file mode 100644 index 000000000..c0dc85a52 --- /dev/null +++ b/app/api/research/insights/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInsightsHandler } from "@/lib/research/getResearchInsightsHandler"; + +/** + * OPTIONS /api/research/insights — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/insights — Noteworthy highlights and trending metrics for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON insights data or error + */ +export async function GET(request: NextRequest) { + return getResearchInsightsHandler(request); +} diff --git a/app/api/research/instagram-posts/route.ts b/app/api/research/instagram-posts/route.ts new file mode 100644 index 000000000..4330e24d1 --- /dev/null +++ b/app/api/research/instagram-posts/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInstagramPostsHandler } from "@/lib/research/getResearchInstagramPostsHandler"; + +/** + * OPTIONS /api/research/instagram-posts — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/instagram-posts — Recent Instagram posts for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON Instagram posts or error + */ +export async function GET(request: NextRequest) { + return getResearchInstagramPostsHandler(request); +} diff --git a/app/api/research/lookup/route.ts b/app/api/research/lookup/route.ts new file mode 100644 index 000000000..668e3f40c --- /dev/null +++ b/app/api/research/lookup/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchLookupHandler } from "@/lib/research/getResearchLookupHandler"; + +/** + * OPTIONS /api/research/lookup — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/lookup — Resolve a Spotify artist URL to cross-platform IDs. Requires `?url=` query param. + * + * @param request - must include `url` query param (Spotify URL) + * @returns JSON cross-platform IDs or error + */ +export async function GET(request: NextRequest) { + return getResearchLookupHandler(request); +} diff --git a/app/api/research/metrics/route.ts b/app/api/research/metrics/route.ts new file mode 100644 index 000000000..8cd2f12b4 --- /dev/null +++ b/app/api/research/metrics/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMetricsHandler } from "@/lib/research/getResearchMetricsHandler"; + +/** + * OPTIONS /api/research/metrics — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/metrics — Platform-specific streaming and social metrics. Requires `?artist=` and `?source=` query params. + * + * @param request - must include `artist` and `source` query params + * @returns JSON metrics data or error + */ +export async function GET(request: NextRequest) { + return getResearchMetricsHandler(request); +} diff --git a/app/api/research/milestones/route.ts b/app/api/research/milestones/route.ts new file mode 100644 index 000000000..63b2f47b2 --- /dev/null +++ b/app/api/research/milestones/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMilestonesHandler } from "@/lib/research/getResearchMilestonesHandler"; + +/** + * OPTIONS /api/research/milestones — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/milestones — Artist activity feed: playlist adds, chart entries, events. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON milestone activity feed or error + */ +export async function GET(request: NextRequest) { + return getResearchMilestonesHandler(request); +} diff --git a/app/api/research/people/route.ts b/app/api/research/people/route.ts new file mode 100644 index 000000000..ea8f121fe --- /dev/null +++ b/app/api/research/people/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchPeopleHandler } from "@/lib/research/postResearchPeopleHandler"; + +/** + * OPTIONS /api/research/people — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/people — Search for people in the music industry. Body: `{ query, num_results? }`. + * + * @param request - JSON body with `query` string + * @returns JSON people results or error + */ +export async function POST(request: NextRequest) { + return postResearchPeopleHandler(request); +} diff --git a/app/api/research/playlist/route.ts b/app/api/research/playlist/route.ts new file mode 100644 index 000000000..db8e8c067 --- /dev/null +++ b/app/api/research/playlist/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistHandler } from "@/lib/research/getResearchPlaylistHandler"; + +/** + * OPTIONS /api/research/playlist — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/playlist — Details for a specific playlist. Requires `?platform=` and `?id=` query params. + * + * @param request - must include `platform` and `id` query params + * @returns JSON playlist details or error + */ +export async function GET(request: NextRequest) { + return getResearchPlaylistHandler(request); +} diff --git a/app/api/research/playlists/route.ts b/app/api/research/playlists/route.ts new file mode 100644 index 000000000..1df1615bc --- /dev/null +++ b/app/api/research/playlists/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistsHandler } from "@/lib/research/getResearchPlaylistsHandler"; + +/** + * OPTIONS /api/research/playlists — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/playlists — Playlists featuring an artist on a given platform. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON playlist placements or error + */ +export async function GET(request: NextRequest) { + return getResearchPlaylistsHandler(request); +} diff --git a/app/api/research/profile/route.ts b/app/api/research/profile/route.ts new file mode 100644 index 000000000..d5c9d7074 --- /dev/null +++ b/app/api/research/profile/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchProfileHandler } from "@/lib/research/getResearchProfileHandler"; + +/** + * OPTIONS /api/research/profile — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/profile — Full artist profile with bio, genres, social URLs, and label info. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON artist profile or error + */ +export async function GET(request: NextRequest) { + return getResearchProfileHandler(request); +} diff --git a/app/api/research/radio/route.ts b/app/api/research/radio/route.ts new file mode 100644 index 000000000..351c52a27 --- /dev/null +++ b/app/api/research/radio/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRadioHandler } from "@/lib/research/getResearchRadioHandler"; + +/** + * OPTIONS /api/research/radio — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/radio — List of radio stations. + * + * @param request - optional filter query params + * @returns JSON radio station list or error + */ +export async function GET(request: NextRequest) { + return getResearchRadioHandler(request); +} diff --git a/app/api/research/rank/route.ts b/app/api/research/rank/route.ts new file mode 100644 index 000000000..87c1768df --- /dev/null +++ b/app/api/research/rank/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRankHandler } from "@/lib/research/getResearchRankHandler"; + +/** + * OPTIONS /api/research/rank — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/rank — Artist's global ranking data. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON ranking data or error + */ +export async function GET(request: NextRequest) { + return getResearchRankHandler(request); +} diff --git a/app/api/research/route.ts b/app/api/research/route.ts new file mode 100644 index 000000000..5a2690cf5 --- /dev/null +++ b/app/api/research/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSearchHandler } from "@/lib/research/getResearchSearchHandler"; + +/** + * OPTIONS /api/research — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research — Search for artists by name. Requires `?q=` query param. + * + * @param request - must include `q` query param + * @returns JSON search results or error + */ +export async function GET(request: NextRequest) { + return getResearchSearchHandler(request); +} diff --git a/app/api/research/similar/route.ts b/app/api/research/similar/route.ts new file mode 100644 index 000000000..78bea1c32 --- /dev/null +++ b/app/api/research/similar/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSimilarHandler } from "@/lib/research/getResearchSimilarHandler"; + +/** + * OPTIONS /api/research/similar — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/similar — Similar artists by audience, genre, mood, or musicality. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON array of similar artists or error + */ +export async function GET(request: NextRequest) { + return getResearchSimilarHandler(request); +} diff --git a/app/api/research/track/route.ts b/app/api/research/track/route.ts new file mode 100644 index 000000000..e8fce5ed7 --- /dev/null +++ b/app/api/research/track/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTrackHandler } from "@/lib/research/getResearchTrackHandler"; + +/** + * OPTIONS /api/research/track — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/track — Search for a track by name and return full details. Requires `?q=` query param. + * + * @param request - must include `q` query param + * @returns JSON track details or error + */ +export async function GET(request: NextRequest) { + return getResearchTrackHandler(request); +} diff --git a/app/api/research/tracks/route.ts b/app/api/research/tracks/route.ts new file mode 100644 index 000000000..fbb5f60f2 --- /dev/null +++ b/app/api/research/tracks/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTracksHandler } from "@/lib/research/getResearchTracksHandler"; + +/** + * OPTIONS /api/research/tracks — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/tracks — All tracks for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON track list or error + */ +export async function GET(request: NextRequest) { + return getResearchTracksHandler(request); +} diff --git a/app/api/research/urls/route.ts b/app/api/research/urls/route.ts new file mode 100644 index 000000000..e95a5ca65 --- /dev/null +++ b/app/api/research/urls/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchUrlsHandler } from "@/lib/research/getResearchUrlsHandler"; + +/** + * OPTIONS /api/research/urls — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/urls — All known platform URLs for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON platform URLs or error + */ +export async function GET(request: NextRequest) { + return getResearchUrlsHandler(request); +} diff --git a/app/api/research/venues/route.ts b/app/api/research/venues/route.ts new file mode 100644 index 000000000..2db8bfb31 --- /dev/null +++ b/app/api/research/venues/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchVenuesHandler } from "@/lib/research/getResearchVenuesHandler"; + +/** + * OPTIONS /api/research/venues — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/venues — Venues an artist has performed at. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON venue list or error + */ +export async function GET(request: NextRequest) { + return getResearchVenuesHandler(request); +} diff --git a/app/api/research/web/route.ts b/app/api/research/web/route.ts new file mode 100644 index 000000000..9362207b7 --- /dev/null +++ b/app/api/research/web/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchWebHandler } from "@/lib/research/postResearchWebHandler"; + +/** + * OPTIONS /api/research/web — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/web — Search the web for real-time information. Body: `{ query, max_results?, country? }`. + * + * @param request - JSON body with `query` string + * @returns JSON search results with formatted markdown or error + */ +export async function POST(request: NextRequest) { + return postResearchWebHandler(request); +} diff --git a/lib/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts new file mode 100644 index 000000000..aa85699df --- /dev/null +++ b/lib/chartmetric/__tests__/getChartmetricToken.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getChartmetricToken, resetTokenCache } from "../getChartmetricToken"; + +describe("getChartmetricToken", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + resetTokenCache(); + process.env = { ...originalEnv }; + }); + + it("throws when CHARTMETRIC_REFRESH_TOKEN is not set", async () => { + delete process.env.CHARTMETRIC_REFRESH_TOKEN; + + await expect(getChartmetricToken()).rejects.toThrow("CHARTMETRIC_REFRESH_TOKEN"); + }); + + it("returns token on successful exchange", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ token: "test-access-token", expires_in: 3600 }), + } as Response); + + const token = await getChartmetricToken(); + + expect(token).toBe("test-access-token"); + expect(fetch).toHaveBeenCalledWith( + "https://api.chartmetric.com/api/token", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ refreshtoken: "test-refresh-token" }), + }), + ); + }); + + it("throws when token exchange returns non-ok response", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 401, + } as Response); + + await expect(getChartmetricToken()).rejects.toThrow("401"); + }); + + it("caches the token and does not fetch again on second call", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ token: "cached-token", expires_in: 3600 }), + } as Response); + + const token1 = await getChartmetricToken(); + const token2 = await getChartmetricToken(); + + expect(token1).toBe("cached-token"); + expect(token2).toBe("cached-token"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws when response has no token", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ expires_in: 3600 }), + } as Response); + + await expect(getChartmetricToken()).rejects.toThrow("token"); + }); +}); diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts new file mode 100644 index 000000000..8f26ec652 --- /dev/null +++ b/lib/chartmetric/getChartmetricToken.ts @@ -0,0 +1,60 @@ +let cachedToken: string | null = null; +let tokenExpiresAt = 0; + +/** + * Reset cached token — for testing only. + * + * @internal + */ +export function resetTokenCache(): void { + cachedToken = null; + tokenExpiresAt = 0; +} + +/** + * Exchanges the Chartmetric refresh token for a short-lived access token. + * Caches the token until 60 seconds before expiry to avoid redundant API calls. + * + * @returns The Chartmetric access token string. + * @throws Error if the token exchange fails or the env variable is missing. + */ +export async function getChartmetricToken(): Promise { + if (cachedToken && Date.now() < tokenExpiresAt) { + return cachedToken; + } + + const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; + + if (!refreshToken) { + throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); + } + + const response = await fetch("https://api.chartmetric.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshtoken: refreshToken }), + }); + + if (!response.ok) { + throw new Error(`Chartmetric token exchange failed with status ${response.status}`); + } + + const data = (await response.json()) as { + token?: string; + access_token?: string; + expires_in: number; + }; + + const token = data.token || data.access_token; + + if (!token) { + throw new Error("Chartmetric token response did not include a token"); + } + + cachedToken = token; + tokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000; + + return token; +} diff --git a/lib/exa/searchPeople.ts b/lib/exa/searchPeople.ts new file mode 100644 index 000000000..581b61224 --- /dev/null +++ b/lib/exa/searchPeople.ts @@ -0,0 +1,66 @@ +const EXA_BASE_URL = "https://api.exa.ai"; + +export interface ExaPersonResult { + title: string; + url: string; + id: string; + publishedDate?: string; + author?: string; + highlights?: string[]; + summary?: string; +} + +export interface ExaPeopleResponse { + results: ExaPersonResult[]; + requestId: string; +} + +/** + * Searches Exa's people index for individuals matching the query. + * Uses Exa's category: "people" filter for multi-source people data + * including LinkedIn profiles. + * + * @param query - Natural language search (e.g., "A&R reps at Atlantic Records") + * @param numResults - Number of results to return (default 10, max 100) + * @returns People search results with highlights + */ +export async function searchPeople( + query: string, + numResults: number = 10, +): Promise { + const safeNumResults = Math.min(100, Math.max(1, Math.floor(numResults))); + const apiKey = process.env.EXA_API_KEY; + + if (!apiKey) { + throw new Error("EXA_API_KEY environment variable is not set"); + } + + const response = await fetch(`${EXA_BASE_URL}/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + query, + category: "people", + numResults: safeNumResults, + contents: { + highlights: { maxCharacters: 4000 }, + summary: true, + }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Exa API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + const data = await response.json(); + + return { + results: data.results || [], + requestId: data.requestId || "", + }; +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index e95da17fb..2d78cc3ac 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -15,6 +15,7 @@ import { registerAllFileTools } from "./files"; import { registerAllFlamingoTools } from "./flamingo"; import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; +import { registerAllResearchTools } from "./research"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; import { registerAllArtistTools } from "./artists"; @@ -54,4 +55,5 @@ export const registerAllTools = (server: McpServer): void => { registerUpdateAccountInfoTool(server); registerCreateSegmentsTool(server); registerAllYouTubeTools(server); + registerAllResearchTools(server); }; diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts new file mode 100644 index 000000000..423a767a1 --- /dev/null +++ b/lib/mcp/tools/research/index.ts @@ -0,0 +1,64 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerResearchArtistTool } from "./registerResearchArtistTool"; +import { registerResearchMetricsTool } from "./registerResearchMetricsTool"; +import { registerResearchAudienceTool } from "./registerResearchAudienceTool"; +import { registerResearchCitiesTool } from "./registerResearchCitiesTool"; +import { registerResearchSimilarTool } from "./registerResearchSimilarTool"; +import { registerResearchPlaylistsTool } from "./registerResearchPlaylistsTool"; +import { registerResearchPeopleTool } from "./registerResearchPeopleTool"; +import { registerResearchExtractTool } from "./registerResearchExtractTool"; +import { registerResearchEnrichTool } from "./registerResearchEnrichTool"; +import { registerResearchUrlsTool } from "./registerResearchUrlsTool"; +import { registerResearchInstagramPostsTool } from "./registerResearchInstagramPostsTool"; +import { registerResearchAlbumsTool } from "./registerResearchAlbumsTool"; +import { registerResearchTracksTool } from "./registerResearchTracksTool"; +import { registerResearchCareerTool } from "./registerResearchCareerTool"; +import { registerResearchInsightsTool } from "./registerResearchInsightsTool"; +import { registerResearchLookupTool } from "./registerResearchLookupTool"; +import { registerResearchTrackTool } from "./registerResearchTrackTool"; +import { registerResearchPlaylistTool } from "./registerResearchPlaylistTool"; +import { registerResearchCuratorTool } from "./registerResearchCuratorTool"; +import { registerResearchDiscoverTool } from "./registerResearchDiscoverTool"; +import { registerResearchGenresTool } from "./registerResearchGenresTool"; +import { registerResearchFestivalsTool } from "./registerResearchFestivalsTool"; +import { registerResearchMilestonesTool } from "./registerResearchMilestonesTool"; +import { registerResearchVenuesTool } from "./registerResearchVenuesTool"; +import { registerResearchRankTool } from "./registerResearchRankTool"; +import { registerResearchChartsTool } from "./registerResearchChartsTool"; +import { registerResearchRadioTool } from "./registerResearchRadioTool"; +import { registerResearchSearchTool } from "./registerResearchSearchTool"; +/** + * Registers all research-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllResearchTools = (server: McpServer): void => { + registerResearchArtistTool(server); + registerResearchMetricsTool(server); + registerResearchAudienceTool(server); + registerResearchCitiesTool(server); + registerResearchSimilarTool(server); + registerResearchPlaylistsTool(server); + registerResearchPeopleTool(server); + registerResearchExtractTool(server); + registerResearchEnrichTool(server); + registerResearchUrlsTool(server); + registerResearchInstagramPostsTool(server); + registerResearchAlbumsTool(server); + registerResearchTracksTool(server); + registerResearchCareerTool(server); + registerResearchInsightsTool(server); + registerResearchLookupTool(server); + registerResearchTrackTool(server); + registerResearchPlaylistTool(server); + registerResearchCuratorTool(server); + registerResearchDiscoverTool(server); + registerResearchGenresTool(server); + registerResearchFestivalsTool(server); + registerResearchMilestonesTool(server); + registerResearchVenuesTool(server); + registerResearchRankTool(server); + registerResearchChartsTool(server); + registerResearchRadioTool(server); + registerResearchSearchTool(server); +}; diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts new file mode 100644 index 000000000..806d49f94 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAlbumsTool.ts @@ -0,0 +1,67 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_albums" tool on the MCP server. + * Returns an artist's full discography — albums, EPs, and singles with release dates. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchAlbumsTool(server: McpServer): void { + server.registerTool( + "get_artist_discography", + { + description: + "Get an artist's full cross-platform discography — albums, EPs, and singles with release dates. Accepts artist name. For Spotify-specific album data with track listings, use get_spotify_artist_albums instead.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/albums`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const data = result.data; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ + albums: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch albums", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchArtistTool.ts b/lib/mcp/tools/research/registerResearchArtistTool.ts new file mode 100644 index 000000000..a7402cc1c --- /dev/null +++ b/lib/mcp/tools/research/registerResearchArtistTool.ts @@ -0,0 +1,66 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_artist" tool on the MCP server. + * Looks up a music artist by name and returns their full Chartmetric profile. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchArtistTool(server: McpServer): void { + server.registerTool( + "get_artist_profile", + { + description: + "Search for a music artist and get their full profile — bio, genres, social URLs, label, and career stage. Pass an artist name.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to research artist", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchAudienceTool.ts b/lib/mcp/tools/research/registerResearchAudienceTool.ts new file mode 100644 index 000000000..544766e32 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAudienceTool.ts @@ -0,0 +1,73 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + platform: z + .enum(["instagram", "tiktok", "youtube"]) + .optional() + .default("instagram") + .describe("Platform for audience data (default: instagram)"), +}); + +/** + * Registers the "research_audience" tool on the MCP server. + * Returns audience demographics — age, gender, and country breakdown. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchAudienceTool(server: McpServer): void { + server.registerTool( + "get_artist_audience", + { + description: + "Get audience demographics for an artist — age, gender, and country breakdown. " + + "Defaults to Instagram. Also supports tiktok and youtube.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const platform = args.platform ?? "instagram"; + const result = await proxyToChartmetric( + `/artist/${resolved.id}/${platform}-audience-stats`, + ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch audience data", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchCareerTool.ts b/lib/mcp/tools/research/registerResearchCareerTool.ts new file mode 100644 index 000000000..bcdea6fa2 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCareerTool.ts @@ -0,0 +1,64 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_career" tool on the MCP server. + * Returns an artist's career timeline — key milestones, trajectory, and career stage. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchCareerTool(server: McpServer): void { + server.registerTool( + "get_artist_career", + { + description: + "Get an artist's career timeline — key milestones, trajectory, and career stage.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/career`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch career data", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchChartsTool.ts b/lib/mcp/tools/research/registerResearchChartsTool.ts new file mode 100644 index 000000000..6023d22fa --- /dev/null +++ b/lib/mcp/tools/research/registerResearchChartsTool.ts @@ -0,0 +1,79 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + platform: z + .string() + .describe("Chart platform: spotify, applemusic, tiktok, youtube, itunes, shazam, etc."), + country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), + interval: z.string().optional().describe("Time interval (e.g. daily, weekly)"), + type: z.string().optional().describe("Chart type (varies by platform)"), + latest: z + .boolean() + .optional() + .default(true) + .describe("Return only the latest chart (default: true)"), +}); + +/** + * Registers the "research_charts" tool on the MCP server. + * Returns global chart positions for a given platform. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchChartsTool(server: McpServer): void { + server.registerTool( + "get_chart_positions", + { + description: + "Get global chart positions for a platform — Spotify, Apple Music, TikTok, YouTube, iTunes, Shazam, etc. " + + "NOT artist-scoped. Returns ranked entries with track/artist info.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + if (!/^[a-zA-Z0-9]+$/.test(args.platform)) { + return getToolResultError("Invalid platform: must be alphanumeric with no slashes"); + } + + const queryParams: Record = {}; + + if (args.country) queryParams.country_code = args.country; + if (args.interval) queryParams.interval = args.interval; + if (args.type) queryParams.type = args.type; + queryParams.latest = String(args.latest ?? true); + + const result = await proxyToChartmetric(`/charts/${args.platform}`, queryParams); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch charts", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchCitiesTool.ts b/lib/mcp/tools/research/registerResearchCitiesTool.ts new file mode 100644 index 000000000..720a92933 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCitiesTool.ts @@ -0,0 +1,81 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_cities" tool on the MCP server. + * Returns the top cities where an artist's fans listen, ranked by listener count. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchCitiesTool(server: McpServer): void { + server.registerTool( + "get_artist_cities", + { + description: + "Get the top cities where an artist's fans listen, ranked by listener concentration. " + + "Shows city name, country, and listener count.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/where-people-listen`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + const raw = + ( + result.data as { + cities?: Record>; + } + )?.cities || {}; + + const cities = Object.entries(raw) + .map(([name, points]) => ({ + name, + country: points[points.length - 1]?.code2 || "", + listeners: points[points.length - 1]?.listeners || 0, + })) + .sort((a, b) => b.listeners - a.listeners); + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ cities }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch cities data", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchCuratorTool.ts b/lib/mcp/tools/research/registerResearchCuratorTool.ts new file mode 100644 index 000000000..93de2a184 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCuratorTool.ts @@ -0,0 +1,66 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + +const schema = z.object({ + platform: z.string().describe("Streaming platform (e.g. spotify)"), + id: z.string().describe("Curator ID"), +}); + +/** + * Registers the "research_curator" tool on the MCP server. + * Returns a curator profile — who curates a playlist, their other playlists, and follower reach. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchCuratorTool(server: McpServer): void { + server.registerTool( + "get_curator_info", + { + description: + "Get curator profile — who curates a playlist, their other playlists, and follower reach.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + if (!VALID_PLATFORMS.includes(args.platform)) { + return getToolResultError( + `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + ); + } + + const result = await proxyToChartmetric(`/curator/${args.platform}/${args.id}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch curator", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchDiscoverTool.ts b/lib/mcp/tools/research/registerResearchDiscoverTool.ts new file mode 100644 index 000000000..c43dff134 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchDiscoverTool.ts @@ -0,0 +1,79 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), + genre: z.number().optional().describe("Genre tag ID from research_genres"), + sp_monthly_listeners_min: z.number().optional().describe("Minimum Spotify monthly listeners"), + sp_monthly_listeners_max: z.number().optional().describe("Maximum Spotify monthly listeners"), + sort: z.string().optional().describe("Sort column (e.g. sp_monthly_listeners, sp_followers)"), + limit: z + .number() + .optional() + .default(20) + .describe("Maximum number of artists to return (default: 20)"), +}); + +/** + * Registers the "research_discover" tool on the MCP server. + * Discovers artists by criteria — country, genre, listener count, and growth rate. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchDiscoverTool(server: McpServer): void { + server.registerTool( + "discover_artists", + { + description: + "Discover artists by criteria — filter by country, genre, listener count, follower count, and growth rate.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const queryParams: Record = {}; + + if (args.country) queryParams.code2 = args.country; + if (args.genre !== undefined) queryParams.tagId = String(args.genre); + if (args.sp_monthly_listeners_min !== undefined) { + queryParams.sp_monthly_listeners_min = String(args.sp_monthly_listeners_min); + } + if (args.sp_monthly_listeners_max !== undefined) { + queryParams.sp_monthly_listeners_max = String(args.sp_monthly_listeners_max); + } + if (args.sort) queryParams.sortColumn = args.sort; + if (args.limit) queryParams.limit = String(args.limit); + + const result = await proxyToChartmetric("/artist/list/filter", queryParams); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to discover artists", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchEnrichTool.ts b/lib/mcp/tools/research/registerResearchEnrichTool.ts new file mode 100644 index 000000000..b296a3266 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchEnrichTool.ts @@ -0,0 +1,69 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { enrichEntity } from "@/lib/parallel/enrichEntity"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + input: z.string().describe("What to research"), + schema: z + .record(z.string(), z.unknown()) + .describe("JSON schema defining the output fields to extract"), + processor: z + .enum(["base", "core", "ultra"]) + .optional() + .default("base") + .describe("Processing tier: base (fast), core (balanced), ultra (comprehensive)"), +}); + +/** + * Registers the "research_enrich" tool on the MCP server. + * Enriches an entity with structured data from web research using Parallel's task API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchEnrichTool(server: McpServer): void { + server.registerTool( + "enrich_entity", + { + description: + "Get structured data about any entity from web research. " + + "Provide a description and a JSON schema defining what fields to extract. " + + "Returns typed data with citations. " + + "Use processor 'base' for fast results, 'core' for balanced, 'ultra' for comprehensive.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await enrichEntity( + args.input, + args.schema as Record, + args.processor ?? "base", + ); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to enrich entity", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchExtractTool.ts b/lib/mcp/tools/research/registerResearchExtractTool.ts new file mode 100644 index 000000000..8f5210ebf --- /dev/null +++ b/lib/mcp/tools/research/registerResearchExtractTool.ts @@ -0,0 +1,61 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { extractUrl } from "@/lib/parallel/extractUrl"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + urls: z.array(z.string()).max(10).describe("URLs to extract content from (max 10)"), + objective: z.string().optional().describe("What information to focus the extraction on"), + full_content: z + .boolean() + .optional() + .describe("Return full page content instead of focused excerpts"), +}); + +/** + * Registers the "research_extract" tool on the MCP server. + * Extracts clean markdown content from public URLs using Parallel's extract API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchExtractTool(server: McpServer): void { + server.registerTool( + "extract_url_content", + { + description: + "Extract clean markdown content from one or more public URLs. " + + "Handles JavaScript-heavy pages and PDFs. " + + "Pass an objective to focus the extraction on specific information.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await extractUrl(args.urls, args.objective, args.full_content ?? false); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to extract URL content", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchFestivalsTool.ts b/lib/mcp/tools/research/registerResearchFestivalsTool.ts new file mode 100644 index 000000000..d57af48d3 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchFestivalsTool.ts @@ -0,0 +1,54 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({}); + +/** + * Registers the "research_festivals" tool on the MCP server. + * Lists music festivals. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchFestivalsTool(server: McpServer): void { + server.registerTool( + "get_festivals", + { + description: "List music festivals.", + inputSchema: schema, + }, + async (_args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await proxyToChartmetric("/festival/list"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch festivals", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchGenresTool.ts b/lib/mcp/tools/research/registerResearchGenresTool.ts new file mode 100644 index 000000000..f239d5496 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchGenresTool.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({}); + +/** + * Registers the "research_genres" tool on the MCP server. + * Lists all available genre IDs and names for use with the discover tool. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchGenresTool(server: McpServer): void { + server.registerTool( + "get_genres", + { + description: + "List all available genre IDs and names. Use these IDs with the research_discover tool.", + inputSchema: schema, + }, + async (_args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await proxyToChartmetric("/genres"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch genres", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchInsightsTool.ts b/lib/mcp/tools/research/registerResearchInsightsTool.ts new file mode 100644 index 000000000..1590ffefd --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInsightsTool.ts @@ -0,0 +1,67 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_insights" tool on the MCP server. + * Returns AI-generated insights about an artist — trends, milestones, and observations. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchInsightsTool(server: McpServer): void { + server.registerTool( + "get_artist_insights", + { + description: + "Get AI-generated insights about an artist — automatically surfaced trends, milestones, and observations.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/noteworthy-insights`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const data = result.data; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ + insights: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch insights", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts new file mode 100644 index 000000000..08e1a268b --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts @@ -0,0 +1,65 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_instagram_posts" tool on the MCP server. + * Returns an artist's top Instagram posts and reels sorted by engagement. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchInstagramPostsTool(server: McpServer): void { + server.registerTool( + "get_artist_instagram_posts", + { + description: "Get an artist's top Instagram posts and reels sorted by engagement.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/SNS/deepSocial/cm_artist/${resolved.id}/instagram`, + ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch Instagram posts", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchLookupTool.ts b/lib/mcp/tools/research/registerResearchLookupTool.ts new file mode 100644 index 000000000..b9f391783 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchLookupTool.ts @@ -0,0 +1,62 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + url: z.string().describe("Spotify URL or platform ID"), +}); + +/** + * Registers the "research_lookup" tool on the MCP server. + * Looks up an artist by a Spotify URL or platform ID and returns the artist profile. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchLookupTool(server: McpServer): void { + server.registerTool( + "lookup_artist_by_url", + { + description: "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const spotifyId = args.url.split("/").pop()?.split("?")[0]; + + if (!spotifyId) { + return getToolResultError("Could not extract Spotify ID from URL"); + } + + const result = await proxyToChartmetric(`/artist/spotify/${spotifyId}/get-ids`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to look up artist", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchMetricsTool.ts b/lib/mcp/tools/research/registerResearchMetricsTool.ts new file mode 100644 index 000000000..c83026339 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMetricsTool.ts @@ -0,0 +1,71 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + source: z + .string() + .describe( + "Platform: spotify, instagram, tiktok, youtube_channel, soundcloud, deezer, twitter, facebook, etc.", + ), +}); + +/** + * Registers the "research_metrics" tool on the MCP server. + * Fetches streaming and social metrics for an artist on a specific platform. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchMetricsTool(server: McpServer): void { + server.registerTool( + "get_artist_metrics", + { + description: + "Get streaming and social metrics for an artist on a specific platform. " + + "Supports 14 platforms including spotify, instagram, tiktok, youtube_channel, " + + "soundcloud, deezer, twitter, facebook.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/stat/${args.source}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch metrics", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchMilestonesTool.ts b/lib/mcp/tools/research/registerResearchMilestonesTool.ts new file mode 100644 index 000000000..a7195c2a8 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMilestonesTool.ts @@ -0,0 +1,66 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_milestones" tool on the MCP server. + * Returns an artist's activity feed — playlist adds, chart entries, and notable events. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchMilestonesTool(server: McpServer): void { + server.registerTool( + "get_artist_milestones", + { + description: + "Get an artist's activity feed — playlist adds, chart entries, and notable events. " + + "Each milestone includes a date, summary, platform, track name, and star rating.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/milestones`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const milestones = (result.data as Record)?.insights || []; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ milestones }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch milestones", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPeopleTool.ts b/lib/mcp/tools/research/registerResearchPeopleTool.ts new file mode 100644 index 000000000..066678adc --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPeopleTool.ts @@ -0,0 +1,60 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { searchPeople } from "@/lib/exa/searchPeople"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + query: z.string().describe("Search query for people"), + num_results: z + .number() + .optional() + .default(10) + .describe("Number of results to return (default: 10)"), +}); + +/** + * Registers the "research_people" tool on the MCP server. + * Searches for people in the music industry using Exa's people index. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchPeopleTool(server: McpServer): void { + server.registerTool( + "find_industry_people", + { + description: + "Search for people in the music industry — artists, managers, A&R reps, producers. " + + "Returns profiles with LinkedIn data and summaries.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await searchPeople(args.query, args.num_results ?? 10); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to search for people", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPlaylistTool.ts b/lib/mcp/tools/research/registerResearchPlaylistTool.ts new file mode 100644 index 000000000..6c3423dc7 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistTool.ts @@ -0,0 +1,88 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + +const schema = z.object({ + platform: z + .enum(["spotify", "applemusic", "deezer", "amazon", "youtube"]) + .describe("Streaming platform"), + id: z.string().describe("Playlist ID or name to search for"), +}); + +/** + * Registers the "research_playlist" tool on the MCP server. + * Returns metadata for a single playlist — name, description, follower count, and curator info. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchPlaylistTool(server: McpServer): void { + server.registerTool( + "get_playlist_info", + { + description: + "Get playlist metadata — name, description, follower count, track count, and curator info.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + if (!VALID_PLATFORMS.includes(args.platform)) { + return getToolResultError( + `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + ); + } + + let numericId = args.id; + + if (!/^\d+$/.test(numericId)) { + const searchResult = await proxyToChartmetric("/search", { + q: numericId, + type: "playlists", + limit: "1", + }); + if (searchResult.status !== 200) { + return getToolResultError(`Request failed with status ${searchResult.status}`); + } + + const playlists = searchResult.data as Record[]; + if (!Array.isArray(playlists) || playlists.length === 0) { + return getToolResultError(`No playlist found for "${args.id}"`); + } + + numericId = String((playlists[0] as Record).id); + } + + const result = await proxyToChartmetric(`/playlist/${args.platform}/${numericId}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch playlist", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts new file mode 100644 index 000000000..802976f32 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts @@ -0,0 +1,110 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + platform: z + .string() + .optional() + .default("spotify") + .describe("Streaming platform (default: spotify)"), + status: z + .string() + .optional() + .default("current") + .describe("Playlist status: current or past (default: current)"), + editorial: z.boolean().optional().describe("Filter to editorial playlists only"), + limit: z + .number() + .optional() + .default(20) + .describe("Maximum number of playlists to return (default: 20)"), +}); + +/** + * Registers the "research_playlists" tool on the MCP server. + * Returns playlist placements for an artist — editorial, algorithmic, and indie. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchPlaylistsTool(server: McpServer): void { + server.registerTool( + "get_artist_playlists", + { + description: + "Get an artist's playlist placements — editorial, algorithmic, and indie playlists. " + + "Shows playlist name, follower count, track name, and curator.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const platform = args.platform ?? "spotify"; + if (!VALID_PLATFORMS.includes(platform)) { + return getToolResultError( + `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + ); + } + const status = args.status ?? "current"; + + const queryParams: Record = {}; + if (args.limit) queryParams.limit = String(args.limit); + + if (args.editorial !== undefined) { + queryParams.editorial = String(args.editorial); + } else { + queryParams.editorial = "true"; + queryParams.indie = "true"; + queryParams.majorCurator = "true"; + queryParams.popularIndie = "true"; + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/${platform}/${status}/playlists`, + queryParams, + ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + const data = result.data; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ + placements: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch playlists", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchRadioTool.ts b/lib/mcp/tools/research/registerResearchRadioTool.ts new file mode 100644 index 000000000..f8c4a9691 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRadioTool.ts @@ -0,0 +1,57 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({}); + +/** + * Registers the "research_radio" tool on the MCP server. + * Returns the list of radio stations tracked by Chartmetric. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchRadioTool(server: McpServer): void { + server.registerTool( + "get_radio_stations", + { + description: + "List radio stations tracked by Chartmetric. " + + "Returns station names, formats, and markets.", + inputSchema: schema, + }, + async (_args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await proxyToChartmetric("/radio/station-list"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const stations = Array.isArray(result.data) ? result.data : []; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ stations }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch radio stations", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchRankTool.ts b/lib/mcp/tools/research/registerResearchRankTool.ts new file mode 100644 index 000000000..93f567e35 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRankTool.ts @@ -0,0 +1,63 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_rank" tool on the MCP server. + * Returns the artist's global Chartmetric ranking. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchRankTool(server: McpServer): void { + server.registerTool( + "get_artist_rank", + { + description: + "Get an artist's global Chartmetric ranking. " + "Returns a single integer rank value.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/artist-rank`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const rank = (result.data as Record)?.artist_rank || null; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ rank }); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch rank"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchSearchTool.ts b/lib/mcp/tools/research/registerResearchSearchTool.ts new file mode 100644 index 000000000..f1e700b91 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchSearchTool.ts @@ -0,0 +1,71 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + query: z.string().describe("Search query — artist name, track title, or keyword"), + type: z + .string() + .optional() + .describe("Result type: artists, tracks, or albums (default: artists)"), + limit: z.string().optional().describe("Max results to return (default: 10)"), +}); + +/** + * Registers the "research_search" tool on the MCP server. + * Searches Chartmetric for artists, tracks, or albums by keyword. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchSearchTool(server: McpServer): void { + server.registerTool( + "search_artists", + { + description: + "Search for music artists, tracks, or albums by keyword. Returns matching results with profile summaries.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await proxyToChartmetric("/search", { + q: args.query, + type: args.type || "artists", + limit: args.limit || "10", + }); + + if (result.status !== 200) { + return getToolResultError(`Search failed with status ${result.status}`); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; + const results = data?.artists || data?.tracks || data?.albums || []; + + return getToolResultSuccess({ results }); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Search failed"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchSimilarTool.ts b/lib/mcp/tools/research/registerResearchSimilarTool.ts new file mode 100644 index 000000000..90d937857 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchSimilarTool.ts @@ -0,0 +1,94 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + audience: z.string().optional().describe("Audience overlap weight: high, medium, or low"), + genre: z.string().optional().describe("Genre similarity weight: high, medium, or low"), + mood: z.string().optional().describe("Mood similarity weight: high, medium, or low"), + musicality: z.string().optional().describe("Musicality similarity weight: high, medium, or low"), + limit: z + .number() + .optional() + .default(10) + .describe("Maximum number of similar artists to return (default: 10)"), +}); + +/** + * Registers the "research_similar" tool on the MCP server. + * Finds similar artists using audience overlap, genre, mood, and musicality weights. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchSimilarTool(server: McpServer): void { + server.registerTool( + "get_similar_artists", + { + description: + "Find similar artists based on audience overlap, genre, mood, and musicality. " + + "Returns career stage, momentum, and streaming numbers for each. " + + "Use for competitive analysis and collaboration discovery.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const hasConfigParams = CONFIG_PARAMS.some(p => args[p] !== undefined); + + const queryParams: Record = {}; + for (const key of CONFIG_PARAMS) { + if (args[key]) queryParams[key] = args[key]; + } + if (args.limit) queryParams.limit = String(args.limit); + + const path = hasConfigParams + ? `/artist/${resolved.id}/similar-artists/by-configurations` + : `/artist/${resolved.id}/relatedartists`; + + const result = await proxyToChartmetric(path, queryParams); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + const data = result.data as Record; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ + artists: Array.isArray(data) ? data : data?.data || [], + total: Array.isArray(data) ? undefined : data?.total, + }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to find similar artists", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchTrackTool.ts b/lib/mcp/tools/research/registerResearchTrackTool.ts new file mode 100644 index 000000000..6e3b9653c --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTrackTool.ts @@ -0,0 +1,70 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + q: z.string().describe("Track name or Spotify URL"), +}); + +/** + * Registers the "research_track" tool on the MCP server. + * Searches for a track by name or URL and returns its metadata. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchTrackTool(server: McpServer): void { + server.registerTool( + "get_track_info", + { + description: "Get track metadata — title, artist, album, release date, and popularity.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const searchResult = await proxyToChartmetric("/search", { + q: args.q, + type: "tracks", + limit: "1", + }); + if (searchResult.status !== 200) { + return getToolResultError(`Request failed with status ${searchResult.status}`); + } + + const searchData = searchResult.data as { tracks?: Array<{ id: number }> }; + const tracks = searchData?.tracks; + if (!Array.isArray(tracks) || tracks.length === 0) { + return getToolResultError(`No track found for "${args.q}"`); + } + + const trackId = tracks[0].id; + const result = await proxyToChartmetric(`/track/${trackId}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch track"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchTracksTool.ts b/lib/mcp/tools/research/registerResearchTracksTool.ts new file mode 100644 index 000000000..fdc2b9b3a --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTracksTool.ts @@ -0,0 +1,67 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_tracks" tool on the MCP server. + * Returns all tracks by an artist with popularity data. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchTracksTool(server: McpServer): void { + server.registerTool( + "get_artist_tracks", + { + description: + "Get all tracks by an artist with popularity data. Accepts artist name. For Spotify top 10 tracks with preview URLs, use get_spotify_artist_top_tracks instead.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const data = result.data; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ + tracks: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch tracks", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchUrlsTool.ts b/lib/mcp/tools/research/registerResearchUrlsTool.ts new file mode 100644 index 000000000..fa9d99068 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchUrlsTool.ts @@ -0,0 +1,62 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_urls" tool on the MCP server. + * Returns all social and streaming URLs for an artist. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchUrlsTool(server: McpServer): void { + server.registerTool( + "get_artist_urls", + { + description: + "Get all known social and streaming URLs for any artist by name — Spotify, Instagram, TikTok, YouTube, Twitter, SoundCloud, and more. For socials connected to a Recoup artist account, use get_artist_socials instead.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/urls`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch URLs"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchVenuesTool.ts b/lib/mcp/tools/research/registerResearchVenuesTool.ts new file mode 100644 index 000000000..22c6d7dd1 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchVenuesTool.ts @@ -0,0 +1,66 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_venues" tool on the MCP server. + * Returns venues the artist has performed at, including capacity and location. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchVenuesTool(server: McpServer): void { + server.registerTool( + "get_artist_venues", + { + description: + "Get venues an artist has performed at. " + + "Includes venue name, capacity, city, country, and event history.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/venues`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const venues = Array.isArray(result.data) ? result.data : []; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ venues }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch venues", + ); + } + }, + ); +} diff --git a/lib/parallel/enrichEntity.ts b/lib/parallel/enrichEntity.ts new file mode 100644 index 000000000..23765abc3 --- /dev/null +++ b/lib/parallel/enrichEntity.ts @@ -0,0 +1,95 @@ +const PARALLEL_BASE_URL = "https://api.parallel.ai/v1"; + +export interface EnrichResult { + run_id: string; + status: string; + output: unknown; + citations?: Array<{ url: string; title?: string; field?: string }>; +} + +/** + * Enriches an entity with structured data from web research. + * Creates a task run and uses the blocking /result endpoint to wait + * for completion (up to timeout seconds). + * + * @param input - What to research (e.g., "Kaash Paige R&B artist") + * @param outputSchema - JSON schema for the structured output + * @param processor - Processor tier: "base" (fast), "core" (balanced), "ultra" (deep) + * @param timeout - Max seconds to wait for result (default 120) + * @returns The enrichment result with structured output and optional citations. + */ +export async function enrichEntity( + input: string, + outputSchema: Record, + processor: "base" | "core" | "ultra" = "base", + timeout: number = 120, +): Promise { + const apiKey = process.env.PARALLEL_API_KEY; + + if (!apiKey) { + throw new Error("PARALLEL_API_KEY environment variable is not set"); + } + + const createResponse = await fetch(`${PARALLEL_BASE_URL}/tasks/runs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + input, + processor, + task_spec: { + output_schema: { + type: "json", + json_schema: outputSchema, + }, + }, + }), + }); + + if (!createResponse.ok) { + const errorText = await createResponse.text(); + throw new Error(`Parallel Task API error: ${createResponse.status}\n${errorText}`); + } + + const taskRun = await createResponse.json(); + const runId = taskRun.run_id; + + if (!runId) { + throw new Error("Parallel Task API did not return a run_id"); + } + + const resultResponse = await fetch( + `${PARALLEL_BASE_URL}/tasks/runs/${runId}/result?timeout=${timeout}`, + { headers: { "x-api-key": apiKey } }, + ); + + if (resultResponse.status === 408) { + return { run_id: runId, status: "timeout", output: null }; + } + + if (resultResponse.status === 404) { + throw new Error("Task run failed or not found"); + } + + if (!resultResponse.ok) { + const errorText = await resultResponse.text(); + throw new Error(`Parallel result fetch failed: ${resultResponse.status}\n${errorText}`); + } + + const resultData = await resultResponse.json(); + const output = resultData.output; + + const citations = (output?.basis || []).flatMap( + (b: { field?: string; citations?: Array<{ url: string; title?: string }> }) => + (b.citations || []).map(c => ({ ...c, field: b.field })), + ); + + return { + run_id: runId, + status: "completed", + output: output?.content, + citations: citations.length > 0 ? citations : undefined, + }; +} diff --git a/lib/parallel/extractUrl.ts b/lib/parallel/extractUrl.ts new file mode 100644 index 000000000..f53748e2d --- /dev/null +++ b/lib/parallel/extractUrl.ts @@ -0,0 +1,60 @@ +const PARALLEL_BASE_URL = "https://api.parallel.ai/v1beta"; + +export interface ExtractResult { + url: string; + title: string | null; + publish_date: string | null; + excerpts: string[] | null; + full_content: string | null; +} + +export interface ExtractResponse { + extract_id: string; + results: ExtractResult[]; + errors: Array<{ url: string; error: string }>; +} + +/** + * Extracts clean markdown content from one or more public URLs. + * Handles JavaScript-heavy pages and PDFs. Returns focused excerpts + * aligned to an objective, or full page content. + * + * @param urls - URLs to extract (max 10 per request) + * @param objective - What information to focus on (optional, max 3000 chars) + * @param fullContent - Return full page content instead of excerpts + * @returns The extraction response with results and any errors. + */ +export async function extractUrl( + urls: string[], + objective?: string, + fullContent: boolean = false, +): Promise { + const apiKey = process.env.PARALLEL_API_KEY; + + if (!apiKey) { + throw new Error("PARALLEL_API_KEY environment variable is not set"); + } + + const response = await fetch(`${PARALLEL_BASE_URL}/extract`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + urls, + ...(objective && { objective }), + excerpts: !fullContent, + full_content: fullContent, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Parallel Extract API error: ${response.status} ${response.statusText}\n${errorText}`, + ); + } + + return await response.json(); +} diff --git a/lib/research/__tests__/getResearchChartsHandler.test.ts b/lib/research/__tests__/getResearchChartsHandler.test.ts new file mode 100644 index 000000000..5ecf99e65 --- /dev/null +++ b/lib/research/__tests__/getResearchChartsHandler.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchChartsHandler } from "../getResearchChartsHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchChartsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + }); + + it("returns 400 when platform is missing", async () => { + const req = new NextRequest("http://localhost/api/research/charts"); + const res = await getResearchChartsHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when platform contains path traversal", async () => { + const req = new NextRequest("http://localhost/api/research/charts?platform=../admin"); + const res = await getResearchChartsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid platform"); + }); + + it("returns 400 when platform contains slashes", async () => { + const req = new NextRequest("http://localhost/api/research/charts?platform=foo/bar"); + const res = await getResearchChartsHandler(req); + expect(res.status).toBe(400); + }); + + it("defaults type to 'regional' and interval to 'daily'", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { chart: [] }, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&country=US"); + await getResearchChartsHandler(req); + + const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + expect(calledParams).toHaveProperty("type", "regional"); + expect(calledParams).toHaveProperty("interval", "daily"); + expect(calledParams).toHaveProperty("country_code", "US"); + }); + + it("preserves user-provided type and interval", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { chart: [] }, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/charts?platform=spotify&type=viral&interval=weekly&country=US", + ); + await getResearchChartsHandler(req); + + const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + expect(calledParams).toMatchObject({ type: "viral", interval: "weekly" }); + }); +}); diff --git a/lib/research/__tests__/getResearchDiscoverHandler.test.ts b/lib/research/__tests__/getResearchDiscoverHandler.test.ts new file mode 100644 index 000000000..783301c41 --- /dev/null +++ b/lib/research/__tests__/getResearchDiscoverHandler.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchDiscoverHandler } from "../getResearchDiscoverHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchDiscoverHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/discover?country=US"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when country is not 2 letters", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + const req = new NextRequest("http://localhost/api/research/discover?country=USA"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.status).toBe("error"); + expect(body.error).toContain("2-letter"); + }); + + it("returns 400 when limit is negative", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + const req = new NextRequest("http://localhost/api/research/discover?limit=-5"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(400); + }); + + it("returns artists on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [ + { name: "Artist A", sp_monthly_listeners: 100000 }, + { name: "Artist B", sp_monthly_listeners: 200000 }, + ], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/discover?country=US&limit=10"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.artists).toHaveLength(2); + expect(body.artists[0].name).toBe("Artist A"); + }); + + it("passes sp_ml range when both min and max provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [], + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/discover?sp_monthly_listeners_min=50000&sp_monthly_listeners_max=200000", + ); + await getResearchDiscoverHandler(req); + + expect(proxyToChartmetric).toHaveBeenCalledWith( + "/artist/list/filter", + expect.objectContaining({ "sp_ml[]": "50000,200000" }), + ); + }); + + it("returns empty array when proxy fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: null, + status: 500, + }); + + const req = new NextRequest("http://localhost/api/research/discover?country=US"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/research/__tests__/getResearchLookupHandler.test.ts b/lib/research/__tests__/getResearchLookupHandler.test.ts new file mode 100644 index 000000000..0a47b4585 --- /dev/null +++ b/lib/research/__tests__/getResearchLookupHandler.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchLookupHandler } from "../getResearchLookupHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchLookupHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + }); + + it("returns 400 when url is missing", async () => { + const req = new NextRequest("http://localhost/api/research/lookup"); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when url is not a Spotify artist URL", async () => { + const req = new NextRequest("http://localhost/api/research/lookup?url=https://google.com"); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Spotify artist URL"); + }); + + it("wraps array responses in a data field instead of spreading indices", async () => { + const arrayData = [ + { id: 1, platform: "spotify" }, + { id: 2, platform: "apple_music" }, + ]; + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: arrayData, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + ); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.status).toBe("success"); + // Should wrap in data field, NOT spread as {"0":...,"1":...} + expect(body.data).toEqual(arrayData); + expect(body).not.toHaveProperty("0"); + }); + + it("spreads object responses normally", async () => { + const objectData = { id: 3380, spotify_id: "3TVXtAsR1Inumwj472S9r4" }; + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: objectData, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + ); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.id).toBe(3380); + expect(body.spotify_id).toBe("3TVXtAsR1Inumwj472S9r4"); + }); +}); diff --git a/lib/research/__tests__/getResearchMetricsHandler.test.ts b/lib/research/__tests__/getResearchMetricsHandler.test.ts new file mode 100644 index 000000000..709b8bdd8 --- /dev/null +++ b/lib/research/__tests__/getResearchMetricsHandler.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchMetricsHandler } from "../getResearchMetricsHandler"; +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(), +})); + +vi.mock("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchMetricsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when source is missing", async () => { + const req = new NextRequest("http://localhost/api/research/metrics?artist=Drake"); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when source contains path traversal characters", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/metrics?artist=Drake&source=../admin", + ); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid source"); + }); + + it("returns 400 when source contains slashes", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/metrics?artist=Drake&source=foo/bar", + ); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when source contains encoded slashes", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/metrics?artist=Drake&source=foo%2fbar", + ); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + }); +}); diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts new file mode 100644 index 000000000..35528954f --- /dev/null +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchSearchHandler } from "../getResearchSearchHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchSearchHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research?q=Drake"); + const res = await getResearchSearchHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when q param is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research"); + const res = await getResearchSearchHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("q parameter is required"); + }); + + it("returns 200 with results on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { artists: [{ name: "Drake", id: 3380 }] }, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research?q=Drake"); + const res = await getResearchSearchHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.results).toEqual([{ name: "Drake", id: 3380 }]); + }); +}); diff --git a/lib/research/__tests__/getResearchSimilarHandler.test.ts b/lib/research/__tests__/getResearchSimilarHandler.test.ts new file mode 100644 index 000000000..b9230e2b6 --- /dev/null +++ b/lib/research/__tests__/getResearchSimilarHandler.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchSimilarHandler } from "../getResearchSimilarHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchSimilarHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + vi.mocked(resolveArtist).mockResolvedValue({ id: 3380 }); + }); + + it("uses by-configurations path with default params when no config params provided", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [{ id: 100, name: "Kendrick Lamar" }], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); + const res = await getResearchSimilarHandler(req); + expect(res.status).toBe(200); + + // Should call by-configurations, NOT relatedartists + const calledPath = vi.mocked(proxyToChartmetric).mock.calls[0][0]; + expect(calledPath).toContain("by-configurations"); + expect(calledPath).not.toContain("relatedartists"); + }); + + it("uses by-configurations path when config params are provided", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [{ id: 100, name: "Kendrick Lamar" }], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/similar?artist=Drake&genre=high"); + const res = await getResearchSimilarHandler(req); + expect(res.status).toBe(200); + + const calledPath = vi.mocked(proxyToChartmetric).mock.calls[0][0]; + expect(calledPath).toContain("by-configurations"); + }); + + it("passes default medium values for config params when none provided", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); + await getResearchSimilarHandler(req); + + const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + expect(calledParams).toMatchObject({ + audience: "medium", + genre: "medium", + mood: "medium", + musicality: "medium", + }); + }); +}); diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts new file mode 100644 index 000000000..c1ffe96b0 --- /dev/null +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchTrackHandler } from "../getResearchTrackHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchTrackHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); + const res = await getResearchTrackHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when q param is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research/track"); + const res = await getResearchTrackHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("q parameter is required"); + }); + + it("returns 200 with track data on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric) + .mockResolvedValueOnce({ + data: { tracks: [{ id: 12345 }] }, + status: 200, + }) + .mockResolvedValueOnce({ + data: { name: "Hotline Bling", artist: "Drake", id: 12345 }, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); + const res = await getResearchTrackHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.name).toBe("Hotline Bling"); + }); +}); diff --git a/lib/research/__tests__/postResearchWebHandler.test.ts b/lib/research/__tests__/postResearchWebHandler.test.ts new file mode 100644 index 000000000..c7403e843 --- /dev/null +++ b/lib/research/__tests__/postResearchWebHandler.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { postResearchWebHandler } from "../postResearchWebHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; +import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/perplexity/searchPerplexity", () => ({ + searchPerplexity: vi.fn(), +})); + +vi.mock("@/lib/perplexity/formatSearchResultsAsMarkdown", () => ({ + formatSearchResultsAsMarkdown: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("postResearchWebHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/web", { + method: "POST", + body: JSON.stringify({ query: "test" }), + }); + const res = await postResearchWebHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when body is missing query", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research/web", { + method: "POST", + body: JSON.stringify({}), + }); + const res = await postResearchWebHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 200 with results and formatted markdown on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const mockResults = [{ title: "Test", url: "https://example.com", snippet: "..." }]; + vi.mocked(searchPerplexity).mockResolvedValue({ results: mockResults } as never); + vi.mocked(formatSearchResultsAsMarkdown).mockReturnValue("# Results\n..."); + + const req = new NextRequest("http://localhost/api/research/web", { + method: "POST", + body: JSON.stringify({ query: "latest music trends" }), + }); + const res = await postResearchWebHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.results).toEqual(mockResults); + expect(body.formatted).toBe("# Results\n..."); + }); +}); diff --git a/lib/research/__tests__/proxyToChartmetric.test.ts b/lib/research/__tests__/proxyToChartmetric.test.ts new file mode 100644 index 000000000..4e419ca17 --- /dev/null +++ b/lib/research/__tests__/proxyToChartmetric.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { proxyToChartmetric } from "../proxyToChartmetric"; + +vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ + getChartmetricToken: vi.fn().mockResolvedValue("mock-token"), +})); + +const mockFetch = vi.fn(); + +describe("proxyToChartmetric", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + it("strips the obj wrapper from Chartmetric responses", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: { name: "Drake", id: 3380 } }), + } as Response); + + const result = await proxyToChartmetric("/artist/3380"); + + expect(result.data).toEqual({ name: "Drake", id: 3380 }); + expect(result.status).toBe(200); + }); + + it("passes through responses without obj wrapper", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ results: [{ name: "Drake" }] }), + } as Response); + + const result = await proxyToChartmetric("/search", { q: "Drake" }); + + expect(result.data).toEqual({ results: [{ name: "Drake" }] }); + }); + + it("appends query params to the URL", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: [] }), + } as Response); + + await proxyToChartmetric("/search", { q: "Drake", type: "artists" }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("q=Drake"); + expect(calledUrl).toContain("type=artists"); + }); + + it("sends Authorization header with token", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: {} }), + } as Response); + + await proxyToChartmetric("/artist/3380"); + + const calledOpts = mockFetch.mock.calls[0][1]; + expect(calledOpts.headers).toMatchObject({ Authorization: "Bearer mock-token" }); + }); + + it("returns error data on non-ok response", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + const result = await proxyToChartmetric("/artist/99999"); + + expect(result.status).toBe(404); + expect(result.data).toEqual({ error: "Chartmetric API returned 404" }); + }); + + it("skips empty query param values", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: [] }), + } as Response); + + await proxyToChartmetric("/search", { q: "Drake", type: "" }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("q=Drake"); + expect(calledUrl).not.toContain("type="); + }); +}); diff --git a/lib/research/__tests__/resolveArtist.test.ts b/lib/research/__tests__/resolveArtist.test.ts new file mode 100644 index 000000000..8a953e278 --- /dev/null +++ b/lib/research/__tests__/resolveArtist.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveArtist } from "../resolveArtist"; + +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +describe("resolveArtist", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns numeric ID directly", async () => { + const result = await resolveArtist("3380"); + + expect(result).toEqual({ id: 3380 }); + expect(proxyToChartmetric).not.toHaveBeenCalled(); + }); + + it("returns error for UUID (not yet implemented)", async () => { + const result = await resolveArtist("de05ba8c-7e29-4f1a-93a7-3635653599f6"); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("not yet implemented"); + }); + + it("searches Chartmetric by name and returns top match", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { artists: [{ id: 3380, name: "Drake" }] }, + status: 200, + }); + + const result = await resolveArtist("Drake"); + + expect(result).toEqual({ id: 3380 }); + expect(proxyToChartmetric).toHaveBeenCalledWith("/search", { + q: "Drake", + type: "artists", + limit: "1", + }); + }); + + it("returns error when no artist found", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { artists: [] }, + status: 200, + }); + + const result = await resolveArtist("xyznonexistent"); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("No artist found"); + }); + + it("returns error when search fails", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { error: "failed" }, + status: 500, + }); + + const result = await resolveArtist("Drake"); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("500"); + }); + + it("returns error for empty string", async () => { + const result = await resolveArtist(""); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("required"); + }); + + it("trims whitespace from input", async () => { + const result = await resolveArtist(" 3380 "); + + expect(result).toEqual({ id: 3380 }); + }); +}); diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts new file mode 100644 index 000000000..97b9a5cf2 --- /dev/null +++ b/lib/research/getResearchAlbumsHandler.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/albums + * + * Returns the album discography for the given artist. + * Requires `artist` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchAlbumsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/albums`, + undefined, + data => ({ albums: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts new file mode 100644 index 000000000..3615b6775 --- /dev/null +++ b/lib/research/getResearchAudienceHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/audience + * + * Returns audience demographic stats for the given artist on a specific platform. + * Accepts optional `platform` query param (defaults to "instagram"). + * The platform is embedded in the path, not passed as a query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchAudienceHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform") || "instagram"; + + return handleArtistResearch(request, cmId => `/artist/${cmId}/${platform}-audience-stats`); +} diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts new file mode 100644 index 000000000..df8b90236 --- /dev/null +++ b/lib/research/getResearchCareerHandler.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/career + * + * Returns career history and milestones for the given artist. + * Requires `artist` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchCareerHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/career`, + undefined, + data => ({ career: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts new file mode 100644 index 000000000..8de47afa3 --- /dev/null +++ b/lib/research/getResearchChartsHandler.ts @@ -0,0 +1,44 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/charts + * + * Returns global chart positions for a platform. Not artist-scoped. + * Requires `platform` query param. Optional: `country`, `interval`, `type`. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchChartsHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + + if (!platform) { + return NextResponse.json( + { status: "error", error: "platform parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (!/^[a-z]+$/.test(platform)) { + return NextResponse.json( + { status: "error", error: "Invalid platform parameter" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleResearchRequest( + request, + () => `/charts/${platform}`, + sp => { + const params: Record = {}; + params.country_code = sp.get("country") || "US"; + params.interval = sp.get("interval") || "daily"; + params.type = sp.get("type") || "regional"; + params.latest = sp.get("latest") ?? "true"; + return params; + }, + ); +} diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts new file mode 100644 index 000000000..d469cacc3 --- /dev/null +++ b/lib/research/getResearchCitiesHandler.ts @@ -0,0 +1,33 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/cities + * + * Returns geographic listening data showing where people listen to the artist. + * Requires `artist` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchCitiesHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/where-people-listen`, + undefined, + data => { + const raw = + (data as { cities?: Record> }) + ?.cities || {}; + return { + cities: Object.entries(raw) + .map(([name, points]) => ({ + name, + country: points[points.length - 1]?.code2 || "", + listeners: points[points.length - 1]?.listeners || 0, + })) + .sort((a, b) => b.listeners - a.listeners), + }; + }, + ); +} diff --git a/lib/research/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts new file mode 100644 index 000000000..4c8d47bd0 --- /dev/null +++ b/lib/research/getResearchCuratorHandler.ts @@ -0,0 +1,26 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/curator + * + * Returns details for a specific playlist curator. + * + * @param request - Requires `platform` and `id` query params + * @returns The JSON response. + */ +export async function getResearchCuratorHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + const id = searchParams.get("id"); + + if (!platform || !id) { + return NextResponse.json( + { status: "error", error: "platform and id parameters are required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleResearchRequest(request, () => `/curator/${platform}/${id}`); +} diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts new file mode 100644 index 000000000..fee1ad97a --- /dev/null +++ b/lib/research/getResearchDiscoverHandler.ts @@ -0,0 +1,42 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; +import { validateDiscoverQuery } from "@/lib/research/validateDiscoverQuery"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Discover handler — filters artists by country, genre, listener ranges, growth rate. + * + * @param request - query params: country, genre, sort, limit, sp_monthly_listeners_min/max + * @returns JSON artist list or error + */ +export async function getResearchDiscoverHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const validated = validateDiscoverQuery(searchParams); + + if (validated instanceof NextResponse) return validated; + + return handleResearchRequest( + request, + () => "/artist/list/filter", + () => { + const params: Record = {}; + if (validated.country) params.code2 = validated.country; + if (validated.genre) params.tagId = validated.genre; + if (validated.sort) params.sortColumn = validated.sort; + if (validated.limit) params.limit = String(validated.limit); + if ( + validated.sp_monthly_listeners_min !== undefined && + validated.sp_monthly_listeners_max !== undefined + ) { + params["sp_ml[]"] = + `${validated.sp_monthly_listeners_min},${validated.sp_monthly_listeners_max}`; + } else if (validated.sp_monthly_listeners_min !== undefined) { + params["sp_ml[]"] = String(validated.sp_monthly_listeners_min); + } else if (validated.sp_monthly_listeners_max !== undefined) { + params["sp_ml[]"] = String(validated.sp_monthly_listeners_max); + } + return params; + }, + data => ({ artists: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts new file mode 100644 index 000000000..f390e86c7 --- /dev/null +++ b/lib/research/getResearchFestivalsHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/festivals + * + * Returns a list of music festivals. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchFestivalsHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/festival/list", + undefined, + data => ({ festivals: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchGenresHandler.ts b/lib/research/getResearchGenresHandler.ts new file mode 100644 index 000000000..14e32d418 --- /dev/null +++ b/lib/research/getResearchGenresHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/genres + * + * Returns all available genre IDs and names. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchGenresHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/genres", + undefined, + data => ({ genres: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts new file mode 100644 index 000000000..b0cae4a58 --- /dev/null +++ b/lib/research/getResearchInsightsHandler.ts @@ -0,0 +1,21 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/insights + * + * Returns noteworthy insights and highlights for the given artist + * (e.g., trending metrics, chart movements, notable playlist adds). + * Requires `artist` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchInsightsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/noteworthy-insights`, + undefined, + data => ({ insights: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts new file mode 100644 index 000000000..93ca7bdc6 --- /dev/null +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -0,0 +1,16 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/instagram-posts + * + * Returns recent Instagram posts for the given artist via Chartmetric's + * DeepSocial integration. + * Requires `artist` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchInstagramPostsHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`); +} diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts new file mode 100644 index 000000000..f626d2f02 --- /dev/null +++ b/lib/research/getResearchLookupHandler.ts @@ -0,0 +1,70 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; + +/** + * GET /api/research/lookup + * + * Resolves a Spotify artist URL to Chartmetric IDs. Extracts the Spotify artist ID + * from the given URL and calls Chartmetric's get-ids endpoint to retrieve all + * cross-platform identifiers. + * + * @param request - Requires `url` query param containing a Spotify artist URL + * @returns The JSON response. + */ +export async function getResearchLookupHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const url = searchParams.get("url"); + + if (!url) { + return NextResponse.json( + { status: "error", error: "url parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const match = url.match(SPOTIFY_ARTIST_REGEX); + if (!match) { + return NextResponse.json( + { status: "error", error: "url must be a valid Spotify artist URL" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const spotifyId = match[1]; + + const result = await proxyToChartmetric(`/artist/spotify/${spotifyId}/get-ids`); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Lookup failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + const responseData = result.data; + + return NextResponse.json( + { + status: "success", + ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) + ? responseData + : { data: responseData }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts new file mode 100644 index 000000000..c5eb19de3 --- /dev/null +++ b/lib/research/getResearchMetricsHandler.ts @@ -0,0 +1,51 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/metrics + * + * Returns platform-specific streaming/social metrics for the given artist. + * Requires `artist` and `source` query params. Source is a platform like + * "spotify", "youtube", "instagram", etc. and is embedded in the path. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchMetricsHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const source = searchParams.get("source"); + + if (!source) { + return NextResponse.json( + { status: "error", error: "source parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const VALID_SOURCES = [ + "spotify", + "instagram", + "tiktok", + "twitter", + "facebook", + "youtube_channel", + "youtube_artist", + "soundcloud", + "deezer", + "twitch", + "line", + "melon", + "wikipedia", + "bandsintown", + ]; + + if (!VALID_SOURCES.includes(source)) { + return NextResponse.json( + { status: "error", error: `Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}` }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleArtistResearch(request, cmId => `/artist/${cmId}/stat/${source}`); +} diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts new file mode 100644 index 000000000..10eb845cf --- /dev/null +++ b/lib/research/getResearchMilestonesHandler.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/milestones + * + * Returns an artist's activity feed — playlist adds, chart entries, and other + * notable events tracked by Chartmetric. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchMilestonesHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/milestones`, + undefined, + data => ({ milestones: (data as Record)?.insights || [] }), + ); +} diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts new file mode 100644 index 000000000..03a32e141 --- /dev/null +++ b/lib/research/getResearchPlaylistHandler.ts @@ -0,0 +1,91 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Playlist detail handler — looks up a playlist by platform and ID, falling back to name search. + * + * @param request - query params: platform, id + * @returns JSON playlist details or error + */ +export async function getResearchPlaylistHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + const id = searchParams.get("id"); + + if (!platform || !id) { + return NextResponse.json( + { status: "error", error: "platform and id parameters are required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + if (!VALID_PLATFORMS.includes(platform)) { + return NextResponse.json( + { status: "error", error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}` }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + let playlistId = id; + + if (!/^\d+$/.test(id)) { + const searchResult = await proxyToChartmetric("/search", { + q: id, + type: "playlists", + limit: "1", + }); + + if (searchResult.status !== 200) { + return NextResponse.json( + { status: "error", error: `Search failed with status ${searchResult.status}` }, + { status: searchResult.status, headers: getCorsHeaders() }, + ); + } + + const playlists = ( + searchResult.data as { playlists?: { [key: string]: Array<{ id: number }> } } + )?.playlists?.[platform]; + + if (!playlists || playlists.length === 0) { + return NextResponse.json( + { status: "error", error: `No playlist found matching "${id}" on ${platform}` }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + playlistId = String(playlists[0].id); + } + + const result = await proxyToChartmetric(`/playlist/${platform}/${playlistId}`); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Playlist lookup failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts new file mode 100644 index 000000000..04b44185c --- /dev/null +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -0,0 +1,61 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * Playlists handler — returns playlists featuring an artist. Supports `?platform=`, `?status=`, `?limit=`, `?sort=`, `?since=`, and playlist-type filters. + * + * @param request - must include `artist` query param + * @returns JSON playlist placements or error + */ +export async function getResearchPlaylistsHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform") || "spotify"; + const status = searchParams.get("status") || "current"; + + const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + if (!VALID_PLATFORMS.includes(platform)) { + return NextResponse.json( + { status: "error", error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}` }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/${platform}/${status}/playlists`, + sp => { + const params: Record = {}; + const limit = sp.get("limit"); + if (limit) params.limit = limit; + const sort = sp.get("sort"); + if (sort) params.sortColumn = sort; + const since = sp.get("since"); + if (since) params.since = since; + + const hasFilters = + sp.get("editorial") || + sp.get("indie") || + sp.get("majorCurator") || + sp.get("popularIndie") || + sp.get("personalized") || + sp.get("chart"); + if (hasFilters) { + if (sp.get("editorial")) params.editorial = sp.get("editorial")!; + if (sp.get("indie")) params.indie = sp.get("indie")!; + if (sp.get("majorCurator")) params.majorCurator = sp.get("majorCurator")!; + if (sp.get("popularIndie")) params.popularIndie = sp.get("popularIndie")!; + if (sp.get("personalized")) params.personalized = sp.get("personalized")!; + if (sp.get("chart")) params.chart = sp.get("chart")!; + } else { + params.editorial = "true"; + params.indie = "true"; + params.majorCurator = "true"; + params.popularIndie = "true"; + } + + return params; + }, + data => ({ placements: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts new file mode 100644 index 000000000..f09d1c53c --- /dev/null +++ b/lib/research/getResearchProfileHandler.ts @@ -0,0 +1,15 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/profile + * + * Returns the full Chartmetric artist profile for the given artist. + * Requires `artist` query param (name, numeric ID, or UUID). + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchProfileHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}`); +} diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts new file mode 100644 index 000000000..246c51c45 --- /dev/null +++ b/lib/research/getResearchRadioHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/radio + * + * Returns a list of radio stations. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchRadioHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/radio/station-list", + undefined, + data => ({ stations: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts new file mode 100644 index 000000000..4a68f6be4 --- /dev/null +++ b/lib/research/getResearchRankHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/rank + * + * Returns the artist's global Chartmetric ranking. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchRankHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/artist-rank`, + undefined, + data => ({ rank: (data as Record)?.artist_rank || null }), + ); +} diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts new file mode 100644 index 000000000..d7e9c59eb --- /dev/null +++ b/lib/research/getResearchSearchHandler.ts @@ -0,0 +1,52 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Search handler — looks up artists/tracks/albums by name via Chartmetric. + * + * @param request - must include `q` query param + * @returns JSON search results or error + */ +export async function getResearchSearchHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + const type = searchParams.get("type") || "artists"; + const limit = searchParams.get("limit") || "10"; + + if (!q) { + return NextResponse.json( + { status: "error", error: "q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric("/search", { q, type, limit }); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Search failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; + const results = data?.artists || data?.tracks || data?.albums || []; + + return NextResponse.json( + { status: "success", results }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts new file mode 100644 index 000000000..a50db87cc --- /dev/null +++ b/lib/research/getResearchSimilarHandler.ts @@ -0,0 +1,36 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; + +/** + * GET /api/research/similar + * + * Returns similar artists. Uses the configuration-based endpoint when any + * of audience, genre, mood, or musicality params are provided (values: high/medium/low). + * Falls back to the simpler related-artists endpoint when none are present. + * Accepts optional `limit` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchSimilarHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/similar-artists/by-configurations`, + sp => { + const params: Record = {}; + for (const key of CONFIG_PARAMS) { + const val = sp.get(key); + params[key] = val || "medium"; + } + const limit = sp.get("limit"); + if (limit) params.limit = limit; + return params; + }, + data => ({ + artists: Array.isArray(data) ? data : (data as Record)?.data || [], + total: (data as Record)?.total, + }), + ); +} diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts new file mode 100644 index 000000000..da0a196cd --- /dev/null +++ b/lib/research/getResearchTrackHandler.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Track handler — searches Chartmetric for a track by name, then fetches full details for the top match. + * + * @param request - must include `q` query param + * @returns JSON track details or error + */ +export async function getResearchTrackHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + + if (!q) { + return NextResponse.json( + { status: "error", error: "q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const searchResult = await proxyToChartmetric("/search", { + q, + type: "tracks", + limit: "1", + }); + + if (searchResult.status !== 200) { + return NextResponse.json( + { status: "error", error: "Track search failed" }, + { status: searchResult.status, headers: getCorsHeaders() }, + ); + } + + const searchData = searchResult.data as { tracks?: Array<{ id: number }> }; + const tracks = searchData?.tracks; + + if (!tracks || tracks.length === 0) { + return NextResponse.json( + { status: "error", error: `No track found matching "${q}"` }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const trackId = tracks[0].id; + const detailResult = await proxyToChartmetric(`/track/${trackId}`); + + if (detailResult.status !== 200) { + return NextResponse.json( + { status: "error", error: "Failed to fetch track details" }, + { status: detailResult.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + ...(typeof detailResult.data === "object" && detailResult.data !== null + ? detailResult.data + : { data: detailResult.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts new file mode 100644 index 000000000..8664017a9 --- /dev/null +++ b/lib/research/getResearchTracksHandler.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/tracks + * + * Returns all tracks for the given artist. + * Requires `artist` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchTracksHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/tracks`, + undefined, + data => ({ tracks: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts new file mode 100644 index 000000000..d947e4621 --- /dev/null +++ b/lib/research/getResearchUrlsHandler.ts @@ -0,0 +1,28 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/urls + * + * Returns all known platform URLs (Spotify, Apple Music, YouTube, socials, etc.) + * for the given artist. + * Requires `artist` query param. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchUrlsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/urls`, + undefined, + data => ({ + urls: Array.isArray(data) + ? data + : Object.entries(data as Record).map(([domain, url]) => ({ + domain, + url, + })), + }), + ); +} diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts new file mode 100644 index 000000000..be9e10770 --- /dev/null +++ b/lib/research/getResearchVenuesHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/venues + * + * Returns venues the artist has performed at, including capacity and location. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchVenuesHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/venues`, + undefined, + data => ({ venues: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts new file mode 100644 index 000000000..630357dc3 --- /dev/null +++ b/lib/research/handleArtistResearch.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Shared handler for artist-scoped research endpoints. Handles auth, artist resolution, credit deduction, and proxying. + * + * @param request - must include `artist` query param for Chartmetric resolution + * @param buildPath - maps resolved Chartmetric ID to API path + * @param getQueryParams - extracts additional query params from the request + * @param transformResponse - reshapes the proxy response data + * @returns JSON response with artist data or error + */ +export async function handleArtistResearch( + request: NextRequest, + buildPath: (cmId: number) => string, + getQueryParams?: (searchParams: URLSearchParams) => Record, + transformResponse?: (data: unknown) => unknown, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const artist = searchParams.get("artist"); + + if (!artist) { + return NextResponse.json( + { status: "error", error: "artist parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const resolved = await resolveArtist(artist); + if (resolved.error) { + return NextResponse.json( + { status: "error", error: resolved.error }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const path = buildPath(resolved.id); + const queryParams = getQueryParams ? getQueryParams(searchParams) : undefined; + const result = await proxyToChartmetric(path, queryParams); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: `Request failed with status ${result.status}` }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + const responseData = transformResponse ? transformResponse(result.data) : result.data; + + return NextResponse.json( + { + status: "success", + ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) + ? responseData + : { data: responseData }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/handleResearchRequest.ts b/lib/research/handleResearchRequest.ts new file mode 100644 index 000000000..fd4384f25 --- /dev/null +++ b/lib/research/handleResearchRequest.ts @@ -0,0 +1,57 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Shared handler for non-artist-scoped research endpoints. Handles auth, credit deduction, and proxying to Chartmetric. + * + * @param request - incoming HTTP request + * @param buildPath - returns the Chartmetric API path + * @param getQueryParams - extracts query params from the request + * @param transformResponse - reshapes the proxy response data + * @param credits - credits to deduct (default 5) + * @returns JSON response with data or error + */ +export async function handleResearchRequest( + request: NextRequest, + buildPath: () => string, + getQueryParams?: (searchParams: URLSearchParams) => Record, + transformResponse?: (data: unknown) => unknown, + credits: number = 5, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const path = buildPath(); + const queryParams = getQueryParams ? getQueryParams(searchParams) : undefined; + const result = await proxyToChartmetric(path, queryParams); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: `Request failed with status ${result.status}` }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: credits }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + const responseData = transformResponse ? transformResponse(result.data) : result.data; + + return NextResponse.json( + { + status: "success", + ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) + ? responseData + : { data: responseData }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/postResearchDeepHandler.ts b/lib/research/postResearchDeepHandler.ts new file mode 100644 index 000000000..a213fbc57 --- /dev/null +++ b/lib/research/postResearchDeepHandler.ts @@ -0,0 +1,63 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { chatWithPerplexity } from "@/lib/perplexity/chatWithPerplexity"; + +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), +}); + +/** + * Deep research handler — performs comprehensive research via Perplexity sonar-deep-research with citations. + * + * @param request - JSON body with `query` string + * @returns JSON research report with citations or error + */ +export async function postResearchDeepHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; + return NextResponse.json( + { status: "error", error: message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + const result = await chatWithPerplexity( + [{ role: "user", content: body.query }], + "sonar-deep-research", + ); + + try { + await deductCredits({ accountId, creditsToDeduct: 25 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + content: result.content, + citations: result.citations, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Deep research failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchEnrichHandler.ts b/lib/research/postResearchEnrichHandler.ts new file mode 100644 index 000000000..9cd984934 --- /dev/null +++ b/lib/research/postResearchEnrichHandler.ts @@ -0,0 +1,79 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { enrichEntity } from "@/lib/parallel/enrichEntity"; + +const bodySchema = z.object({ + input: z.string().min(1, "input is required"), + schema: z.record(z.string(), z.unknown()), + processor: z.enum(["base", "core", "ultra"]).optional().default("base"), +}); + +/** + * POST /api/research/enrich + * + * Enrich an entity with structured data from web research. + * Provide a description of who/what to research and a JSON schema + * defining what fields to extract. Returns typed data with citations. + * + * @param request - Body: { input, schema, processor? } + * @returns JSON success or error response + */ +export async function postResearchEnrichHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; + return NextResponse.json( + { status: "error", error: message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const creditCost = body.processor === "ultra" ? 25 : body.processor === "core" ? 10 : 5; + + try { + const result = await enrichEntity(body.input, body.schema, body.processor); + + if (result.status === "timeout") { + return NextResponse.json( + { + status: "error", + error: "Enrichment timed out. Try a simpler schema or use processor: 'base'.", + run_id: result.run_id, + }, + { status: 504, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: creditCost }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + output: result.output, + citations: result.citations, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Enrichment failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchExtractHandler.ts b/lib/research/postResearchExtractHandler.ts new file mode 100644 index 000000000..525d51429 --- /dev/null +++ b/lib/research/postResearchExtractHandler.ts @@ -0,0 +1,65 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { extractUrl } from "@/lib/parallel/extractUrl"; + +const bodySchema = z.object({ + urls: z.array(z.string().min(1)).min(1).max(10), + objective: z.string().optional(), + full_content: z.boolean().optional(), +}); + +/** + * POST /api/research/extract + * + * Extract clean markdown content from one or more URLs. + * Handles JavaScript-heavy pages and PDFs. + * + * @param request - Body: { urls, objective?, full_content? } + * @returns JSON success or error response + */ +export async function postResearchExtractHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; + return NextResponse.json( + { status: "error", error: message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + const result = await extractUrl(body.urls, body.objective, body.full_content); + + try { + await deductCredits({ accountId, creditsToDeduct: 5 * body.urls.length }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + results: result.results, + errors: result.errors.length > 0 ? result.errors : undefined, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Extract failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchPeopleHandler.ts b/lib/research/postResearchPeopleHandler.ts new file mode 100644 index 000000000..0cc1c2d65 --- /dev/null +++ b/lib/research/postResearchPeopleHandler.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { searchPeople } from "@/lib/exa/searchPeople"; + +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), + num_results: z.coerce.number().int().min(1).max(100).optional(), +}); + +/** + * POST /api/research/people + * + * Search for people in the music industry — artists, managers, + * A&R reps, producers, etc. Uses multi-source people data + * including LinkedIn profiles. + * + * @param request - Body: { query, num_results? } + * @returns JSON success or error response + */ +export async function postResearchPeopleHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; + return NextResponse.json( + { status: "error", error: message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + const result = await searchPeople(body.query, body.num_results ?? 10); + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + results: result.results, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "People search failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchWebHandler.ts b/lib/research/postResearchWebHandler.ts new file mode 100644 index 000000000..5b69533f2 --- /dev/null +++ b/lib/research/postResearchWebHandler.ts @@ -0,0 +1,70 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; +import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; + +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), + max_results: z.coerce.number().int().min(1).max(20).optional(), + country: z.string().length(2).optional(), +}); + +/** + * Web search handler — queries Perplexity for real-time web results with formatted markdown output. + * + * @param request - JSON body with `query`, optional `max_results` and `country` + * @returns JSON search results with formatted markdown or error + */ +export async function postResearchWebHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; + return NextResponse.json( + { status: "error", error: message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + const searchResponse = await searchPerplexity({ + query: body.query, + max_results: body.max_results ?? 10, + max_tokens_per_page: 1024, + ...(body.country && { country: body.country }), + }); + + const formatted = formatSearchResultsAsMarkdown(searchResponse); + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + results: searchResponse.results, + formatted, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Web search failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/proxyToChartmetric.ts b/lib/research/proxyToChartmetric.ts new file mode 100644 index 000000000..aee4f8b5a --- /dev/null +++ b/lib/research/proxyToChartmetric.ts @@ -0,0 +1,57 @@ +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; + +const CHARTMETRIC_BASE = "https://api.chartmetric.com/api"; + +interface ProxyResult { + data: unknown; + status: number; +} + +/** + * Proxies a request to the Chartmetric API with authentication. + * Returns the parsed JSON response with the `obj` wrapper stripped. + * + * @param path - Chartmetric API path (e.g., "/artist/3380/stat/spotify") + * @param queryParams - Optional query parameters to append + * @returns The response data (contents of `obj` if present, otherwise full response) + */ +export async function proxyToChartmetric( + path: string, + queryParams?: Record, +): Promise { + try { + const accessToken = await getChartmetricToken(); + + const url = new URL(`${CHARTMETRIC_BASE}${path}`); + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== "") { + url.searchParams.set(key, value); + } + } + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return { + data: { error: `Chartmetric API returned ${response.status}` }, + status: response.status, + }; + } + + const json = await response.json(); + + const data = json.obj !== undefined ? json.obj : json; + + return { data, status: response.status }; + } catch { + return { data: null, status: 500 }; + } +} diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts new file mode 100644 index 000000000..86b76bbf5 --- /dev/null +++ b/lib/research/resolveArtist.ts @@ -0,0 +1,53 @@ +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Resolves an artist identifier (name, UUID, or numeric ID) to a Chartmetric artist ID. + * + * - Numeric string → used directly as Chartmetric ID + * - UUID → future: look up mapping. For now, returns error. + * - String → searches Chartmetric by name, returns top match ID + * + * @param artist - Artist name, Recoup artist ID (UUID), or numeric ID + * @returns The Chartmetric artist ID, or null if not found + */ +export async function resolveArtist( + artist: string, +): Promise<{ id: number; error?: never } | { id?: never; error: string }> { + if (!artist || !artist.trim()) { + return { error: "artist parameter is required" }; + } + + const trimmed = artist.trim(); + + if (/^\d+$/.test(trimmed)) { + return { id: parseInt(trimmed, 10) }; + } + + if (UUID_REGEX.test(trimmed)) { + // TODO: Look up Recoup artist ID → Chartmetric ID mapping in database + return { + error: "Recoup artist ID resolution is not yet implemented. Use an artist name instead.", + }; + } + + const result = await proxyToChartmetric("/search", { + q: trimmed, + type: "artists", + limit: "1", + }); + + if (result.status !== 200) { + return { error: `Search failed with status ${result.status}` }; + } + + const data = result.data as { artists?: Array<{ id: number; name: string }> }; + const artists = data?.artists; + + if (!artists || artists.length === 0) { + return { error: `No artist found matching "${trimmed}"` }; + } + + return { id: artists[0].id }; +} diff --git a/lib/research/validateDiscoverQuery.ts b/lib/research/validateDiscoverQuery.ts new file mode 100644 index 000000000..f0a548837 --- /dev/null +++ b/lib/research/validateDiscoverQuery.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const discoverQuerySchema = z.object({ + country: z.string().length(2, "country must be a 2-letter ISO code").optional(), + genre: z.string().optional(), + sort: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + sp_monthly_listeners_min: z.coerce.number().int().min(0).optional(), + sp_monthly_listeners_max: z.coerce.number().int().min(0).optional(), +}); + +export type DiscoverQuery = z.infer; + +/** + * Validates query params for GET /api/research/discover. + */ +export function validateDiscoverQuery(searchParams: URLSearchParams): NextResponse | DiscoverQuery { + const raw: Record = {}; + for (const key of [ + "country", + "genre", + "sort", + "limit", + "sp_monthly_listeners_min", + "sp_monthly_listeners_max", + ]) { + const val = searchParams.get(key); + if (val) raw[key] = val; + } + + const result = discoverQuerySchema.safeParse(raw); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} From 9b9f60a5e53b94750f9bb04d5c41b648d206f8df Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:51:31 -0400 Subject: [PATCH 02/28] feat: add GET /api/research/track/playlists endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track-level playlist lookup — returns editorial, indie, and algorithmic playlists for a specific track. Accepts Chartmetric track ID or track name (resolved via search). Proxies to Chartmetric /track/{id}/{platform}/{status}/playlists. Includes route handler, domain handler, MCP tool, and 8 unit tests. Made-with: Cursor --- app/api/research/track/playlists/route.ts | 22 +++ lib/mcp/tools/research/index.ts | 2 + .../registerResearchTrackPlaylistsTool.ts | 137 +++++++++++++ .../getResearchTrackPlaylistsHandler.test.ts | 181 ++++++++++++++++++ .../getResearchTrackPlaylistsHandler.ts | 146 ++++++++++++++ 5 files changed, 488 insertions(+) create mode 100644 app/api/research/track/playlists/route.ts create mode 100644 lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts create mode 100644 lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts create mode 100644 lib/research/getResearchTrackPlaylistsHandler.ts diff --git a/app/api/research/track/playlists/route.ts b/app/api/research/track/playlists/route.ts new file mode 100644 index 000000000..0eb5c13ff --- /dev/null +++ b/app/api/research/track/playlists/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTrackPlaylistsHandler } from "@/lib/research/getResearchTrackPlaylistsHandler"; + +/** + * OPTIONS /api/research/track/playlists — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/track/playlists — Playlists featuring a specific track. Requires `?id=` or `?q=` query param. + * + * @param request - must include `id` (Chartmetric track ID) or `q` (track name) query param + * @returns JSON playlist placements for the track or error + */ +export async function GET(request: NextRequest) { + return getResearchTrackPlaylistsHandler(request); +} diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts index 423a767a1..9830107b5 100644 --- a/lib/mcp/tools/research/index.ts +++ b/lib/mcp/tools/research/index.ts @@ -27,6 +27,7 @@ import { registerResearchRankTool } from "./registerResearchRankTool"; import { registerResearchChartsTool } from "./registerResearchChartsTool"; import { registerResearchRadioTool } from "./registerResearchRadioTool"; import { registerResearchSearchTool } from "./registerResearchSearchTool"; +import { registerResearchTrackPlaylistsTool } from "./registerResearchTrackPlaylistsTool"; /** * Registers all research-related MCP tools on the server. * @@ -61,4 +62,5 @@ export const registerAllResearchTools = (server: McpServer): void => { registerResearchChartsTool(server); registerResearchRadioTool(server); registerResearchSearchTool(server); + registerResearchTrackPlaylistsTool(server); }; diff --git a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts new file mode 100644 index 000000000..762321a56 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts @@ -0,0 +1,137 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; + +const schema = z.object({ + id: z + .string() + .optional() + .describe("Chartmetric track ID. Provide this or q."), + q: z + .string() + .optional() + .describe("Track name to search for. Provide this or id."), + platform: z + .string() + .optional() + .default("spotify") + .describe("Streaming platform (default: spotify)"), + status: z + .string() + .optional() + .default("current") + .describe("Playlist status: current or past (default: current)"), + editorial: z.boolean().optional().describe("Filter to editorial playlists only"), + limit: z + .number() + .optional() + .default(10) + .describe("Maximum number of playlists to return (default: 10)"), +}); + +/** + * Registers the "get_track_playlists" tool on the MCP server. + * Returns playlist placements for a specific track. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchTrackPlaylistsTool(server: McpServer): void { + server.registerTool( + "get_track_playlists", + { + description: + "Get playlists featuring a specific track. " + + "Use this to find which editorial, indie, and algorithmic playlists a particular song is on. " + + "Returns playlist name, cover image, follower count, and curator.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + if (!args.id && !args.q) { + return getToolResultError("Either id or q parameter is required"); + } + + try { + let trackId = args.id; + + if (!trackId) { + const searchResult = await proxyToChartmetric("/search", { + q: args.q!, + type: "tracks", + limit: "1", + }); + + if (searchResult.status !== 200) { + return getToolResultError(`Track search failed with status ${searchResult.status}`); + } + + const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; + if (!tracks || tracks.length === 0) { + return getToolResultError(`No track found matching "${args.q}"`); + } + + trackId = String(tracks[0].id); + } + + const platform = args.platform ?? "spotify"; + if (!VALID_PLATFORMS.includes(platform)) { + return getToolResultError( + `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + ); + } + const status = args.status ?? "current"; + + const queryParams: Record = {}; + if (args.limit) queryParams.limit = String(args.limit); + + if (args.editorial !== undefined) { + queryParams.editorial = String(args.editorial); + } else { + queryParams.editorial = "true"; + queryParams.indie = "true"; + queryParams.majorCurator = "true"; + queryParams.popularIndie = "true"; + } + + const result = await proxyToChartmetric( + `/track/${trackId}/${platform}/${status}/playlists`, + queryParams, + ); + + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + return getToolResultSuccess({ + placements: Array.isArray(result.data) ? result.data : [], + }); + } catch (err) { + return getToolResultError( + err instanceof Error ? err.message : "Failed to fetch track playlists", + ); + } + }, + ); +} diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts new file mode 100644 index 000000000..40a5138b2 --- /dev/null +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchTrackPlaylistsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/track/playlists?id=18220712"); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when both id and q are missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research/track/playlists"); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("id or q parameter is required"); + }); + + it("returns 400 for invalid platform", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=123&platform=invalid", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid platform"); + }); + + it("returns 400 for invalid status", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=123&status=invalid", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("status must be"); + }); + + it("returns 200 with playlists when given a track id", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [ + { + playlist: { name: "Chill Vibes", image_url: "https://i.scdn.co/image/abc", editorial: true }, + track: { name: "God's Plan", cm_track: 18220712 }, + }, + ], + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=18220712&editorial=true", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.placements).toHaveLength(1); + expect(body.placements[0].playlist.name).toBe("Chill Vibes"); + }); + + it("resolves track by name when q is provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric) + .mockResolvedValueOnce({ + data: { tracks: [{ id: 18220712 }] }, + status: 200, + }) + .mockResolvedValueOnce({ + data: [ + { + playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, + track: { name: "God's Plan" }, + }, + ], + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?q=God%27s+Plan+Drake", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.placements).toHaveLength(1); + }); + + it("returns 404 when track name search finds nothing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { tracks: [] }, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?q=nonexistent+song", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(404); + }); + + it("returns empty placements when Chartmetric returns non-array", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: null, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/track/playlists?id=123"); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.placements).toEqual([]); + }); +}); diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts new file mode 100644 index 000000000..b6456b3bb --- /dev/null +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -0,0 +1,146 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; + +/** + * Track playlists handler — returns playlists featuring a specific track. + * Accepts a Chartmetric track ID or a track name (resolved via search). + * + * @param request - query params: id or q, platform, status, editorial, limit + * @returns JSON playlist placements for the track or error + */ +export async function getResearchTrackPlaylistsHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const q = searchParams.get("q"); + + if (!id && !q) { + return NextResponse.json( + { status: "error", error: "id or q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const platform = searchParams.get("platform") || "spotify"; + if (!VALID_PLATFORMS.includes(platform)) { + return NextResponse.json( + { + status: "error", + error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const status = searchParams.get("status") || "current"; + if (status !== "current" && status !== "past") { + return NextResponse.json( + { status: "error", error: "status must be 'current' or 'past'" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + let trackId = id; + + if (!trackId) { + const searchResult = await proxyToChartmetric("/search", { + q: q!, + type: "tracks", + limit: "1", + }); + + if (searchResult.status !== 200) { + return NextResponse.json( + { status: "error", error: `Track search failed with status ${searchResult.status}` }, + { status: searchResult.status, headers: getCorsHeaders() }, + ); + } + + const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; + if (!tracks || tracks.length === 0) { + return NextResponse.json( + { status: "error", error: `No track found matching "${q}"` }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + trackId = String(tracks[0].id); + } + + const queryParams: Record = {}; + const limit = searchParams.get("limit"); + if (limit) queryParams.limit = limit; + const offset = searchParams.get("offset"); + if (offset) queryParams.offset = offset; + const since = searchParams.get("since"); + if (since) queryParams.since = since; + const until = searchParams.get("until"); + if (until) queryParams.until = until; + const sortColumn = searchParams.get("sort"); + if (sortColumn) queryParams.sortColumn = sortColumn; + + const filterParams = [ + "editorial", + "indie", + "majorCurator", + "popularIndie", + "personalized", + "chart", + "newMusicFriday", + "thisIs", + "radio", + "brand", + ]; + + let hasFilters = false; + for (const param of filterParams) { + const value = searchParams.get(param); + if (value !== null) { + queryParams[param] = value; + hasFilters = true; + } + } + + if (!hasFilters) { + queryParams.editorial = "true"; + queryParams.indie = "true"; + queryParams.majorCurator = "true"; + queryParams.popularIndie = "true"; + } + + const result = await proxyToChartmetric( + `/track/${trackId}/${platform}/${status}/playlists`, + queryParams, + ); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: `Request failed with status ${result.status}` }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + return NextResponse.json( + { + status: "success", + placements: Array.isArray(result.data) ? result.data : [], + }, + { status: 200, headers: getCorsHeaders() }, + ); +} From ed94ece3f0ffdba2a591d2c63da5d213cc2ed898 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:57:39 -0400 Subject: [PATCH 03/28] fix: use Spotify-powered track search for reliable q= resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds resolveTrack() — searches Spotify first (accurate matching with artist: filter), maps Spotify track ID to Chartmetric ID, falls back to Chartmetric search if Spotify fails. Adds optional artist= param to track/playlists endpoint and MCP tool. Made-with: Cursor --- .../registerResearchTrackPlaylistsTool.ts | 24 +++--- .../getResearchTrackPlaylistsHandler.test.ts | 38 ++++----- .../getResearchTrackPlaylistsHandler.ts | 28 ++----- lib/research/resolveTrack.ts | 80 +++++++++++++++++++ 4 files changed, 116 insertions(+), 54 deletions(-) create mode 100644 lib/research/resolveTrack.ts diff --git a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts index 762321a56..2e665265a 100644 --- a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts +++ b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { resolveTrack } from "@/lib/research/resolveTrack"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; @@ -20,6 +21,10 @@ const schema = z.object({ .string() .optional() .describe("Track name to search for. Provide this or id."), + artist: z + .string() + .optional() + .describe("Artist name — improves track search accuracy when using q."), platform: z .string() .optional() @@ -71,22 +76,11 @@ export function registerResearchTrackPlaylistsTool(server: McpServer): void { let trackId = args.id; if (!trackId) { - const searchResult = await proxyToChartmetric("/search", { - q: args.q!, - type: "tracks", - limit: "1", - }); - - if (searchResult.status !== 200) { - return getToolResultError(`Track search failed with status ${searchResult.status}`); + const resolved = await resolveTrack(args.q!, args.artist); + if (resolved.error) { + return getToolResultError(resolved.error); } - - const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; - if (!tracks || tracks.length === 0) { - return getToolResultError(`No track found matching "${args.q}"`); - } - - trackId = String(tracks[0].id); + trackId = resolved.id; } const platform = args.platform ?? "spotify"; diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 40a5138b2..5789e4512 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { resolveTrack } from "@/lib/research/resolveTrack"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -17,6 +18,10 @@ vi.mock("@/lib/research/proxyToChartmetric", () => ({ proxyToChartmetric: vi.fn(), })); +vi.mock("@/lib/research/resolveTrack", () => ({ + resolveTrack: vi.fn(), +})); + vi.mock("@/lib/credits/deductCredits", () => ({ deductCredits: vi.fn(), })); @@ -116,29 +121,27 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric) - .mockResolvedValueOnce({ - data: { tracks: [{ id: 18220712 }] }, - status: 200, - }) - .mockResolvedValueOnce({ - data: [ - { - playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, - track: { name: "God's Plan" }, - }, - ], - status: 200, - }); + vi.mocked(resolveTrack).mockResolvedValue({ id: "18220712" }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [ + { + playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, + track: { name: "God's Plan" }, + }, + ], + status: 200, + }); const req = new NextRequest( - "http://localhost/api/research/track/playlists?q=God%27s+Plan+Drake", + "http://localhost/api/research/track/playlists?q=God%27s+Plan&artist=Drake", ); const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe("success"); expect(body.placements).toHaveLength(1); + expect(vi.mocked(resolveTrack)).toHaveBeenCalledWith("God's Plan", "Drake"); }); it("returns 404 when track name search finds nothing", async () => { @@ -148,10 +151,7 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric).mockResolvedValue({ - data: { tracks: [] }, - status: 200, - }); + vi.mocked(resolveTrack).mockResolvedValue({ error: "No track found matching \"nonexistent song\"" }); const req = new NextRequest( "http://localhost/api/research/track/playlists?q=nonexistent+song", diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts index b6456b3bb..bf201a951 100644 --- a/lib/research/getResearchTrackPlaylistsHandler.ts +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -3,14 +3,15 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { resolveTrack } from "@/lib/research/resolveTrack"; const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; /** * Track playlists handler — returns playlists featuring a specific track. - * Accepts a Chartmetric track ID or a track name (resolved via search). + * Accepts a Chartmetric track ID, or a track name + optional artist for Spotify-powered lookup. * - * @param request - query params: id or q, platform, status, editorial, limit + * @param request - query params: id or q (+artist), platform, status, editorial, limit * @returns JSON playlist placements for the track or error */ export async function getResearchTrackPlaylistsHandler( @@ -23,6 +24,7 @@ export async function getResearchTrackPlaylistsHandler( const { searchParams } = new URL(request.url); const id = searchParams.get("id"); const q = searchParams.get("q"); + const artist = searchParams.get("artist") || undefined; if (!id && !q) { return NextResponse.json( @@ -53,28 +55,14 @@ export async function getResearchTrackPlaylistsHandler( let trackId = id; if (!trackId) { - const searchResult = await proxyToChartmetric("/search", { - q: q!, - type: "tracks", - limit: "1", - }); - - if (searchResult.status !== 200) { - return NextResponse.json( - { status: "error", error: `Track search failed with status ${searchResult.status}` }, - { status: searchResult.status, headers: getCorsHeaders() }, - ); - } - - const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; - if (!tracks || tracks.length === 0) { + const resolved = await resolveTrack(q!, artist); + if (resolved.error) { return NextResponse.json( - { status: "error", error: `No track found matching "${q}"` }, + { status: "error", error: resolved.error }, { status: 404, headers: getCorsHeaders() }, ); } - - trackId = String(tracks[0].id); + trackId = resolved.id; } const queryParams: Record = {}; diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts new file mode 100644 index 000000000..f905b7def --- /dev/null +++ b/lib/research/resolveTrack.ts @@ -0,0 +1,80 @@ +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getSearch from "@/lib/spotify/getSearch"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +interface SpotifyTrackItem { + id: string; + name: string; + artists: Array<{ name: string }>; + external_ids?: { isrc?: string }; +} + +/** + * Resolves a track name (+ optional artist) to a Chartmetric track ID. + * + * Uses Spotify search for accurate matching, then maps the Spotify track ID + * to a Chartmetric ID via their /track/spotify/{id} endpoint. + * + * Falls back to Chartmetric's own search if Spotify lookup fails. + */ +export async function resolveTrack( + q: string, + artist?: string, +): Promise<{ id: string; error?: never } | { id?: never; error: string }> { + const searchQuery = artist ? `${q} artist:${artist}` : q; + + const tokenResult = await generateAccessToken(); + if (tokenResult.error || !tokenResult.access_token) { + return fallbackChartmetricSearch(q); + } + + const { data, error } = await getSearch({ + q: searchQuery, + type: "track", + limit: 1, + accessToken: tokenResult.access_token, + }); + + if (error || !data) { + return fallbackChartmetricSearch(q); + } + + const tracks: SpotifyTrackItem[] = data?.tracks?.items ?? []; + if (tracks.length === 0) { + return { error: `No track found matching "${q}"${artist ? ` by ${artist}` : ""}` }; + } + + const spotifyTrackId = tracks[0].id; + + const cmResult = await proxyToChartmetric(`/track/spotify/${spotifyTrackId}`); + if (cmResult.status === 200 && cmResult.data) { + const cmData = cmResult.data as { id?: number } | Array<{ id?: number }>; + const cmId = Array.isArray(cmData) ? cmData[0]?.id : cmData.id; + if (cmId) { + return { id: String(cmId) }; + } + } + + return fallbackChartmetricSearch(q); +} + +async function fallbackChartmetricSearch( + q: string, +): Promise<{ id: string; error?: never } | { id?: never; error: string }> { + const result = await proxyToChartmetric("/search", { + q, + type: "tracks", + limit: "1", + }); + + if (result.status !== 200) { + return { error: `Track search failed with status ${result.status}` }; + } + + const tracks = (result.data as { tracks?: Array<{ id: number }> })?.tracks; + if (!tracks || tracks.length === 0) { + return { error: `No track found matching "${q}"` }; + } + + return { id: String(tracks[0].id) }; +} From c62fc774bd8717dc5c14a2365e4a205eb5b4ee3f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:01:00 -0400 Subject: [PATCH 04/28] fix: resolve tracks via ISRC for reliable Chartmetric ID mapping Spotify search returns ISRC, which maps to Chartmetric more reliably than Spotify track ID. Tries /track/isrc/{isrc} first, then /track/spotify/{id}, then falls back to Chartmetric text search. Made-with: Cursor --- lib/research/resolveTrack.ts | 53 ++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index f905b7def..1d72366d6 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -12,10 +12,10 @@ interface SpotifyTrackItem { /** * Resolves a track name (+ optional artist) to a Chartmetric track ID. * - * Uses Spotify search for accurate matching, then maps the Spotify track ID - * to a Chartmetric ID via their /track/spotify/{id} endpoint. + * Uses Spotify search for accurate matching, then maps the ISRC + * to a Chartmetric ID via their /track/isrc/{isrc} endpoint. * - * Falls back to Chartmetric's own search if Spotify lookup fails. + * Falls back to Chartmetric's own search if Spotify/ISRC lookup fails. */ export async function resolveTrack( q: string, @@ -44,20 +44,51 @@ export async function resolveTrack( return { error: `No track found matching "${q}"${artist ? ` by ${artist}` : ""}` }; } - const spotifyTrackId = tracks[0].id; + const spotifyTrack = tracks[0]; + const isrc = spotifyTrack.external_ids?.isrc; - const cmResult = await proxyToChartmetric(`/track/spotify/${spotifyTrackId}`); - if (cmResult.status === 200 && cmResult.data) { - const cmData = cmResult.data as { id?: number } | Array<{ id?: number }>; - const cmId = Array.isArray(cmData) ? cmData[0]?.id : cmData.id; - if (cmId) { - return { id: String(cmId) }; - } + if (isrc) { + const cmId = await chartmetricIdFromIsrc(isrc); + if (cmId) return { id: cmId }; } + const cmId = await chartmetricIdFromSpotify(spotifyTrack.id); + if (cmId) return { id: cmId }; + return fallbackChartmetricSearch(q); } +/** Extract a Chartmetric track ID from any response shape. */ +function extractCmTrackId(data: unknown): string | null { + if (!data) return null; + + if (Array.isArray(data) && data.length > 0) { + const first = data[0] as Record; + const id = first.cm_track ?? first.id; + if (id != null) return String(id); + } + + if (typeof data === "object" && data !== null) { + const obj = data as Record; + const id = obj.cm_track ?? obj.id; + if (id != null) return String(id); + } + + return null; +} + +async function chartmetricIdFromIsrc(isrc: string): Promise { + const result = await proxyToChartmetric(`/track/isrc/${isrc}`); + if (result.status !== 200) return null; + return extractCmTrackId(result.data); +} + +async function chartmetricIdFromSpotify(spotifyId: string): Promise { + const result = await proxyToChartmetric(`/track/spotify/${spotifyId}`); + if (result.status !== 200) return null; + return extractCmTrackId(result.data); +} + async function fallbackChartmetricSearch( q: string, ): Promise<{ id: string; error?: never } | { id?: never; error: string }> { From e65ef6affca00dea1a1c0c775403ac3f8ceac05c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:04:02 -0400 Subject: [PATCH 05/28] fix: resolve track ID via artist playlists + tracks matching Spotify search finds exact track name, then we match against the artist's Chartmetric playlists/tracks by name to get the cm_track ID. Avoids Chartmetric's broken text search and unreliable ID mapping. Made-with: Cursor --- lib/research/resolveTrack.ts | 111 ++++++++++++++--------------------- 1 file changed, 44 insertions(+), 67 deletions(-) diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 1d72366d6..967c59d17 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,21 +1,14 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; - -interface SpotifyTrackItem { - id: string; - name: string; - artists: Array<{ name: string }>; - external_ids?: { isrc?: string }; -} +import { resolveArtist } from "@/lib/research/resolveArtist"; /** * Resolves a track name (+ optional artist) to a Chartmetric track ID. * - * Uses Spotify search for accurate matching, then maps the ISRC - * to a Chartmetric ID via their /track/isrc/{isrc} endpoint. - * - * Falls back to Chartmetric's own search if Spotify/ISRC lookup fails. + * Strategy: Spotify search finds the exact track name, then we look through + * the artist's Chartmetric playlists to find the matching cm_track ID. + * This avoids Chartmetric's unreliable text search entirely. */ export async function resolveTrack( q: string, @@ -25,7 +18,7 @@ export async function resolveTrack( const tokenResult = await generateAccessToken(); if (tokenResult.error || !tokenResult.access_token) { - return fallbackChartmetricSearch(q); + return { error: "Failed to authenticate with Spotify" }; } const { data, error } = await getSearch({ @@ -36,7 +29,13 @@ export async function resolveTrack( }); if (error || !data) { - return fallbackChartmetricSearch(q); + return { error: "Spotify search failed" }; + } + + interface SpotifyTrackItem { + id: string; + name: string; + artists: Array<{ name: string }>; } const tracks: SpotifyTrackItem[] = data?.tracks?.items ?? []; @@ -45,67 +44,45 @@ export async function resolveTrack( } const spotifyTrack = tracks[0]; - const isrc = spotifyTrack.external_ids?.isrc; - - if (isrc) { - const cmId = await chartmetricIdFromIsrc(isrc); - if (cmId) return { id: cmId }; - } - - const cmId = await chartmetricIdFromSpotify(spotifyTrack.id); - if (cmId) return { id: cmId }; - - return fallbackChartmetricSearch(q); -} - -/** Extract a Chartmetric track ID from any response shape. */ -function extractCmTrackId(data: unknown): string | null { - if (!data) return null; + const exactName = spotifyTrack.name; + const artistName = artist || spotifyTrack.artists?.[0]?.name; - if (Array.isArray(data) && data.length > 0) { - const first = data[0] as Record; - const id = first.cm_track ?? first.id; - if (id != null) return String(id); + if (!artistName) { + return { error: `Found track "${exactName}" but could not determine artist` }; } - if (typeof data === "object" && data !== null) { - const obj = data as Record; - const id = obj.cm_track ?? obj.id; - if (id != null) return String(id); + const resolved = await resolveArtist(artistName); + if (resolved.error) { + return { error: `Artist lookup failed: ${resolved.error}` }; } - return null; -} - -async function chartmetricIdFromIsrc(isrc: string): Promise { - const result = await proxyToChartmetric(`/track/isrc/${isrc}`); - if (result.status !== 200) return null; - return extractCmTrackId(result.data); -} - -async function chartmetricIdFromSpotify(spotifyId: string): Promise { - const result = await proxyToChartmetric(`/track/spotify/${spotifyId}`); - if (result.status !== 200) return null; - return extractCmTrackId(result.data); -} - -async function fallbackChartmetricSearch( - q: string, -): Promise<{ id: string; error?: never } | { id?: never; error: string }> { - const result = await proxyToChartmetric("/search", { - q, - type: "tracks", - limit: "1", - }); - - if (result.status !== 200) { - return { error: `Track search failed with status ${result.status}` }; + const playlistsResult = await proxyToChartmetric( + `/artist/${resolved.id}/spotify/current/playlists`, + { editorial: "true", indie: "true", majorCurator: "true", popularIndie: "true", limit: "100" }, + ); + + if (playlistsResult.status === 200 && Array.isArray(playlistsResult.data)) { + const normalizedTarget = exactName.toLowerCase(); + for (const placement of playlistsResult.data as Array>) { + const trackName = String(placement.track ?? "").toLowerCase(); + const cmTrack = placement.cm_track; + if (cmTrack && trackName.includes(normalizedTarget)) { + return { id: String(cmTrack) }; + } + } } - const tracks = (result.data as { tracks?: Array<{ id: number }> })?.tracks; - if (!tracks || tracks.length === 0) { - return { error: `No track found matching "${q}"` }; + const tracksResult = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); + if (tracksResult.status === 200 && Array.isArray(tracksResult.data)) { + const normalizedTarget = exactName.toLowerCase(); + for (const track of tracksResult.data as Array>) { + const trackName = String(track.name ?? "").toLowerCase(); + const cmTrack = track.cm_track ?? track.id; + if (cmTrack && trackName.includes(normalizedTarget)) { + return { id: String(cmTrack) }; + } + } } - return { id: String(tracks[0].id) }; + return { error: `Could not find Chartmetric ID for "${exactName}" by ${artistName}` }; } From 46d970135b4d9a3d79e42a05d6343dce8a00d72f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:10:44 -0400 Subject: [PATCH 06/28] fix: use Chartmetric /track/:type/:id/get-ids for track resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps ISRC → chartmetric_ids via the correct endpoint path. Falls back to Spotify track ID if ISRC lookup fails. Platform-agnostic. Made-with: Cursor --- lib/research/resolveTrack.ts | 62 +++++++++++++----------------------- 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 967c59d17..ce41526fe 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,14 +1,17 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import { resolveArtist } from "@/lib/research/resolveArtist"; + +interface GetIdsResponse { + chartmetric_ids?: number[]; +} /** * Resolves a track name (+ optional artist) to a Chartmetric track ID. * - * Strategy: Spotify search finds the exact track name, then we look through - * the artist's Chartmetric playlists to find the matching cm_track ID. - * This avoids Chartmetric's unreliable text search entirely. + * Uses Spotify search for accurate matching, gets the ISRC, then maps + * to a Chartmetric ID via /track/isrc/{isrc}/get-ids. + * Works across all platforms since ISRC is a universal identifier. */ export async function resolveTrack( q: string, @@ -35,7 +38,7 @@ export async function resolveTrack( interface SpotifyTrackItem { id: string; name: string; - artists: Array<{ name: string }>; + external_ids?: { isrc?: string }; } const tracks: SpotifyTrackItem[] = data?.tracks?.items ?? []; @@ -44,45 +47,24 @@ export async function resolveTrack( } const spotifyTrack = tracks[0]; - const exactName = spotifyTrack.name; - const artistName = artist || spotifyTrack.artists?.[0]?.name; - - if (!artistName) { - return { error: `Found track "${exactName}" but could not determine artist` }; - } + const isrc = spotifyTrack.external_ids?.isrc; - const resolved = await resolveArtist(artistName); - if (resolved.error) { - return { error: `Artist lookup failed: ${resolved.error}` }; - } - - const playlistsResult = await proxyToChartmetric( - `/artist/${resolved.id}/spotify/current/playlists`, - { editorial: "true", indie: "true", majorCurator: "true", popularIndie: "true", limit: "100" }, - ); - - if (playlistsResult.status === 200 && Array.isArray(playlistsResult.data)) { - const normalizedTarget = exactName.toLowerCase(); - for (const placement of playlistsResult.data as Array>) { - const trackName = String(placement.track ?? "").toLowerCase(); - const cmTrack = placement.cm_track; - if (cmTrack && trackName.includes(normalizedTarget)) { - return { id: String(cmTrack) }; - } + if (isrc) { + const result = await proxyToChartmetric(`/track/isrc/${isrc}/get-ids`); + if (result.status === 200) { + const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; + const cmId = ids?.chartmetric_ids?.[0]; + if (cmId) return { id: String(cmId) }; } } - const tracksResult = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); - if (tracksResult.status === 200 && Array.isArray(tracksResult.data)) { - const normalizedTarget = exactName.toLowerCase(); - for (const track of tracksResult.data as Array>) { - const trackName = String(track.name ?? "").toLowerCase(); - const cmTrack = track.cm_track ?? track.id; - if (cmTrack && trackName.includes(normalizedTarget)) { - return { id: String(cmTrack) }; - } - } + const spotifyId = spotifyTrack.id; + const result = await proxyToChartmetric(`/track/spotify/${spotifyId}/get-ids`); + if (result.status === 200) { + const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; + const cmId = ids?.chartmetric_ids?.[0]; + if (cmId) return { id: String(cmId) }; } - return { error: `Could not find Chartmetric ID for "${exactName}" by ${artistName}` }; + return { error: `Could not resolve Chartmetric ID for "${spotifyTrack.name}"` }; } From ae185ef01f5a042f2e8be1294eeb62679ef49cd5 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:02:10 -0400 Subject: [PATCH 07/28] style: fix formatting in research track playlists files Made-with: Cursor --- .../research/registerResearchTrackPlaylistsTool.ts | 10 ++-------- .../getResearchTrackPlaylistsHandler.test.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts index 2e665265a..764e9d5d5 100644 --- a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts +++ b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts @@ -13,14 +13,8 @@ import { deductCredits } from "@/lib/credits/deductCredits"; const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; const schema = z.object({ - id: z - .string() - .optional() - .describe("Chartmetric track ID. Provide this or q."), - q: z - .string() - .optional() - .describe("Track name to search for. Provide this or id."), + id: z.string().optional().describe("Chartmetric track ID. Provide this or q."), + q: z.string().optional().describe("Track name to search for. Provide this or id."), artist: z .string() .optional() diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 5789e4512..60df198ee 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -96,7 +96,11 @@ describe("getResearchTrackPlaylistsHandler", () => { vi.mocked(proxyToChartmetric).mockResolvedValue({ data: [ { - playlist: { name: "Chill Vibes", image_url: "https://i.scdn.co/image/abc", editorial: true }, + playlist: { + name: "Chill Vibes", + image_url: "https://i.scdn.co/image/abc", + editorial: true, + }, track: { name: "God's Plan", cm_track: 18220712 }, }, ], @@ -151,11 +155,11 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(resolveTrack).mockResolvedValue({ error: "No track found matching \"nonexistent song\"" }); + vi.mocked(resolveTrack).mockResolvedValue({ + error: 'No track found matching "nonexistent song"', + }); - const req = new NextRequest( - "http://localhost/api/research/track/playlists?q=nonexistent+song", - ); + const req = new NextRequest("http://localhost/api/research/track/playlists?q=nonexistent+song"); const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(404); }); From de07d4be6669c64880cd1b7f9ea95bc24689a639 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 14:51:30 -0500 Subject: [PATCH 08/28] fix(lint): remove unused getCorsHeaders import Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/research/getResearchDiscoverHandler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts index fee1ad97a..fc114c049 100644 --- a/lib/research/getResearchDiscoverHandler.ts +++ b/lib/research/getResearchDiscoverHandler.ts @@ -1,7 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; import { validateDiscoverQuery } from "@/lib/research/validateDiscoverQuery"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; /** * Discover handler — filters artists by country, genre, listener ranges, growth rate. From ac7865d4e7544e5891669d1fc32bdedb62d8d615 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 14:59:12 -0500 Subject: [PATCH 09/28] refactor(review): SRP on chartmetric token cache; drop MCP research tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on this PR: SRP — resetTokenCache in its own file: - Extract shared cache state into lib/chartmetric/chartmetricTokenCache.ts - Move resetTokenCache to lib/chartmetric/resetTokenCache.ts - getChartmetricToken now reads/writes the shared cache module Remove MCP research tools to keep this PR focused on the API surface: - Delete lib/mcp/tools/research/ (29 register* files + index) - Remove registerAllResearchTools import/call from lib/mcp/tools/index.ts - MCP tools can land in a follow-up PR once the HTTP endpoints ship Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/getChartmetricToken.test.ts | 3 +- .../__tests__/resetTokenCache.test.ts | 39 ++++++ lib/chartmetric/chartmetricTokenCache.ts | 14 ++ lib/chartmetric/getChartmetricToken.ts | 21 +-- lib/chartmetric/resetTokenCache.ts | 12 ++ lib/mcp/tools/index.ts | 2 - lib/mcp/tools/research/index.ts | 66 --------- .../research/registerResearchAlbumsTool.ts | 67 ---------- .../research/registerResearchArtistTool.ts | 66 --------- .../research/registerResearchAudienceTool.ts | 73 ---------- .../research/registerResearchCareerTool.ts | 64 --------- .../research/registerResearchChartsTool.ts | 79 ----------- .../research/registerResearchCitiesTool.ts | 81 ------------ .../research/registerResearchCuratorTool.ts | 66 --------- .../research/registerResearchDiscoverTool.ts | 79 ----------- .../research/registerResearchEnrichTool.ts | 69 ---------- .../research/registerResearchExtractTool.ts | 61 --------- .../research/registerResearchFestivalsTool.ts | 54 -------- .../research/registerResearchGenresTool.ts | 55 -------- .../research/registerResearchInsightsTool.ts | 67 ---------- .../registerResearchInstagramPostsTool.ts | 65 --------- .../research/registerResearchLookupTool.ts | 62 --------- .../research/registerResearchMetricsTool.ts | 71 ---------- .../registerResearchMilestonesTool.ts | 66 --------- .../research/registerResearchPeopleTool.ts | 60 --------- .../research/registerResearchPlaylistTool.ts | 88 ------------ .../research/registerResearchPlaylistsTool.ts | 110 --------------- .../research/registerResearchRadioTool.ts | 57 -------- .../research/registerResearchRankTool.ts | 63 --------- .../research/registerResearchSearchTool.ts | 71 ---------- .../research/registerResearchSimilarTool.ts | 94 ------------- .../registerResearchTrackPlaylistsTool.ts | 125 ------------------ .../research/registerResearchTrackTool.ts | 70 ---------- .../research/registerResearchTracksTool.ts | 67 ---------- .../research/registerResearchUrlsTool.ts | 62 --------- .../research/registerResearchVenuesTool.ts | 66 --------- 36 files changed, 72 insertions(+), 2163 deletions(-) create mode 100644 lib/chartmetric/__tests__/resetTokenCache.test.ts create mode 100644 lib/chartmetric/chartmetricTokenCache.ts create mode 100644 lib/chartmetric/resetTokenCache.ts delete mode 100644 lib/mcp/tools/research/index.ts delete mode 100644 lib/mcp/tools/research/registerResearchAlbumsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchArtistTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchAudienceTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchCareerTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchChartsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchCitiesTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchCuratorTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchDiscoverTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchEnrichTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchExtractTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchFestivalsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchGenresTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchInsightsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchInstagramPostsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchLookupTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchMetricsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchMilestonesTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchPeopleTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchPlaylistTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchPlaylistsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchRadioTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchRankTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchSearchTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchSimilarTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchTrackTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchTracksTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchUrlsTool.ts delete mode 100644 lib/mcp/tools/research/registerResearchVenuesTool.ts diff --git a/lib/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts index aa85699df..bcc7a88af 100644 --- a/lib/chartmetric/__tests__/getChartmetricToken.test.ts +++ b/lib/chartmetric/__tests__/getChartmetricToken.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getChartmetricToken, resetTokenCache } from "../getChartmetricToken"; +import { getChartmetricToken } from "../getChartmetricToken"; +import { resetTokenCache } from "../resetTokenCache"; describe("getChartmetricToken", () => { const originalEnv = { ...process.env }; diff --git a/lib/chartmetric/__tests__/resetTokenCache.test.ts b/lib/chartmetric/__tests__/resetTokenCache.test.ts new file mode 100644 index 000000000..ad1aa41eb --- /dev/null +++ b/lib/chartmetric/__tests__/resetTokenCache.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +describe("resetTokenCache", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...OLD_ENV, CHARTMETRIC_REFRESH_TOKEN: "refresh-abc" }; + }); + + afterEach(() => { + process.env = OLD_ENV; + }); + + it("is exported from its own module file", async () => { + const mod = await import("../resetTokenCache"); + expect(typeof mod.resetTokenCache).toBe("function"); + }); + + it("clears the cached token so the next getChartmetricToken refetches", async () => { + const { getChartmetricToken } = await import("../getChartmetricToken"); + const { resetTokenCache } = await import("../resetTokenCache"); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: "t1", expires_in: 3600 }), + }); + vi.stubGlobal("fetch", fetchMock); + + resetTokenCache(); + await getChartmetricToken(); + await getChartmetricToken(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resetTokenCache(); + await getChartmetricToken(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/chartmetric/chartmetricTokenCache.ts b/lib/chartmetric/chartmetricTokenCache.ts new file mode 100644 index 000000000..a3b518f0f --- /dev/null +++ b/lib/chartmetric/chartmetricTokenCache.ts @@ -0,0 +1,14 @@ +/** + * Shared in-memory cache for the short-lived Chartmetric access token. + * + * Isolated in its own module so both `getChartmetricToken` (writer/reader) and + * `resetTokenCache` (test-only clear) can share state without either file + * owning both responsibilities. + */ +export const chartmetricTokenCache: { + token: string | null; + expiresAt: number; +} = { + token: null, + expiresAt: 0, +}; diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts index 8f26ec652..fcfdf52af 100644 --- a/lib/chartmetric/getChartmetricToken.ts +++ b/lib/chartmetric/getChartmetricToken.ts @@ -1,15 +1,4 @@ -let cachedToken: string | null = null; -let tokenExpiresAt = 0; - -/** - * Reset cached token — for testing only. - * - * @internal - */ -export function resetTokenCache(): void { - cachedToken = null; - tokenExpiresAt = 0; -} +import { chartmetricTokenCache } from "./chartmetricTokenCache"; /** * Exchanges the Chartmetric refresh token for a short-lived access token. @@ -19,8 +8,8 @@ export function resetTokenCache(): void { * @throws Error if the token exchange fails or the env variable is missing. */ export async function getChartmetricToken(): Promise { - if (cachedToken && Date.now() < tokenExpiresAt) { - return cachedToken; + if (chartmetricTokenCache.token && Date.now() < chartmetricTokenCache.expiresAt) { + return chartmetricTokenCache.token; } const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; @@ -53,8 +42,8 @@ export async function getChartmetricToken(): Promise { throw new Error("Chartmetric token response did not include a token"); } - cachedToken = token; - tokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000; + chartmetricTokenCache.token = token; + chartmetricTokenCache.expiresAt = Date.now() + (data.expires_in - 60) * 1000; return token; } diff --git a/lib/chartmetric/resetTokenCache.ts b/lib/chartmetric/resetTokenCache.ts new file mode 100644 index 000000000..59f786975 --- /dev/null +++ b/lib/chartmetric/resetTokenCache.ts @@ -0,0 +1,12 @@ +import { chartmetricTokenCache } from "./chartmetricTokenCache"; + +/** + * Reset the cached Chartmetric access token. Test-only utility — lets tests + * observe the fetch path without carrying state across cases. + * + * @internal + */ +export function resetTokenCache(): void { + chartmetricTokenCache.token = null; + chartmetricTokenCache.expiresAt = 0; +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 2d78cc3ac..e95da17fb 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -15,7 +15,6 @@ import { registerAllFileTools } from "./files"; import { registerAllFlamingoTools } from "./flamingo"; import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; -import { registerAllResearchTools } from "./research"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; import { registerAllArtistTools } from "./artists"; @@ -55,5 +54,4 @@ export const registerAllTools = (server: McpServer): void => { registerUpdateAccountInfoTool(server); registerCreateSegmentsTool(server); registerAllYouTubeTools(server); - registerAllResearchTools(server); }; diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts deleted file mode 100644 index 9830107b5..000000000 --- a/lib/mcp/tools/research/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerResearchArtistTool } from "./registerResearchArtistTool"; -import { registerResearchMetricsTool } from "./registerResearchMetricsTool"; -import { registerResearchAudienceTool } from "./registerResearchAudienceTool"; -import { registerResearchCitiesTool } from "./registerResearchCitiesTool"; -import { registerResearchSimilarTool } from "./registerResearchSimilarTool"; -import { registerResearchPlaylistsTool } from "./registerResearchPlaylistsTool"; -import { registerResearchPeopleTool } from "./registerResearchPeopleTool"; -import { registerResearchExtractTool } from "./registerResearchExtractTool"; -import { registerResearchEnrichTool } from "./registerResearchEnrichTool"; -import { registerResearchUrlsTool } from "./registerResearchUrlsTool"; -import { registerResearchInstagramPostsTool } from "./registerResearchInstagramPostsTool"; -import { registerResearchAlbumsTool } from "./registerResearchAlbumsTool"; -import { registerResearchTracksTool } from "./registerResearchTracksTool"; -import { registerResearchCareerTool } from "./registerResearchCareerTool"; -import { registerResearchInsightsTool } from "./registerResearchInsightsTool"; -import { registerResearchLookupTool } from "./registerResearchLookupTool"; -import { registerResearchTrackTool } from "./registerResearchTrackTool"; -import { registerResearchPlaylistTool } from "./registerResearchPlaylistTool"; -import { registerResearchCuratorTool } from "./registerResearchCuratorTool"; -import { registerResearchDiscoverTool } from "./registerResearchDiscoverTool"; -import { registerResearchGenresTool } from "./registerResearchGenresTool"; -import { registerResearchFestivalsTool } from "./registerResearchFestivalsTool"; -import { registerResearchMilestonesTool } from "./registerResearchMilestonesTool"; -import { registerResearchVenuesTool } from "./registerResearchVenuesTool"; -import { registerResearchRankTool } from "./registerResearchRankTool"; -import { registerResearchChartsTool } from "./registerResearchChartsTool"; -import { registerResearchRadioTool } from "./registerResearchRadioTool"; -import { registerResearchSearchTool } from "./registerResearchSearchTool"; -import { registerResearchTrackPlaylistsTool } from "./registerResearchTrackPlaylistsTool"; -/** - * Registers all research-related MCP tools on the server. - * - * @param server - The MCP server instance to register tools on. - */ -export const registerAllResearchTools = (server: McpServer): void => { - registerResearchArtistTool(server); - registerResearchMetricsTool(server); - registerResearchAudienceTool(server); - registerResearchCitiesTool(server); - registerResearchSimilarTool(server); - registerResearchPlaylistsTool(server); - registerResearchPeopleTool(server); - registerResearchExtractTool(server); - registerResearchEnrichTool(server); - registerResearchUrlsTool(server); - registerResearchInstagramPostsTool(server); - registerResearchAlbumsTool(server); - registerResearchTracksTool(server); - registerResearchCareerTool(server); - registerResearchInsightsTool(server); - registerResearchLookupTool(server); - registerResearchTrackTool(server); - registerResearchPlaylistTool(server); - registerResearchCuratorTool(server); - registerResearchDiscoverTool(server); - registerResearchGenresTool(server); - registerResearchFestivalsTool(server); - registerResearchMilestonesTool(server); - registerResearchVenuesTool(server); - registerResearchRankTool(server); - registerResearchChartsTool(server); - registerResearchRadioTool(server); - registerResearchSearchTool(server); - registerResearchTrackPlaylistsTool(server); -}; diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts deleted file mode 100644 index 806d49f94..000000000 --- a/lib/mcp/tools/research/registerResearchAlbumsTool.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_albums" tool on the MCP server. - * Returns an artist's full discography — albums, EPs, and singles with release dates. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchAlbumsTool(server: McpServer): void { - server.registerTool( - "get_artist_discography", - { - description: - "Get an artist's full cross-platform discography — albums, EPs, and singles with release dates. Accepts artist name. For Spotify-specific album data with track listings, use get_spotify_artist_albums instead.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/albums`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - const data = result.data; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ - albums: Array.isArray(data) ? data : [], - }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch albums", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchArtistTool.ts b/lib/mcp/tools/research/registerResearchArtistTool.ts deleted file mode 100644 index a7402cc1c..000000000 --- a/lib/mcp/tools/research/registerResearchArtistTool.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_artist" tool on the MCP server. - * Looks up a music artist by name and returns their full Chartmetric profile. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchArtistTool(server: McpServer): void { - server.registerTool( - "get_artist_profile", - { - description: - "Search for a music artist and get their full profile — bio, genres, social URLs, label, and career stage. Pass an artist name.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to research artist", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchAudienceTool.ts b/lib/mcp/tools/research/registerResearchAudienceTool.ts deleted file mode 100644 index 544766e32..000000000 --- a/lib/mcp/tools/research/registerResearchAudienceTool.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), - platform: z - .enum(["instagram", "tiktok", "youtube"]) - .optional() - .default("instagram") - .describe("Platform for audience data (default: instagram)"), -}); - -/** - * Registers the "research_audience" tool on the MCP server. - * Returns audience demographics — age, gender, and country breakdown. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchAudienceTool(server: McpServer): void { - server.registerTool( - "get_artist_audience", - { - description: - "Get audience demographics for an artist — age, gender, and country breakdown. " + - "Defaults to Instagram. Also supports tiktok and youtube.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const platform = args.platform ?? "instagram"; - const result = await proxyToChartmetric( - `/artist/${resolved.id}/${platform}-audience-stats`, - ); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch audience data", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchCareerTool.ts b/lib/mcp/tools/research/registerResearchCareerTool.ts deleted file mode 100644 index bcdea6fa2..000000000 --- a/lib/mcp/tools/research/registerResearchCareerTool.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_career" tool on the MCP server. - * Returns an artist's career timeline — key milestones, trajectory, and career stage. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchCareerTool(server: McpServer): void { - server.registerTool( - "get_artist_career", - { - description: - "Get an artist's career timeline — key milestones, trajectory, and career stage.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/career`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch career data", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchChartsTool.ts b/lib/mcp/tools/research/registerResearchChartsTool.ts deleted file mode 100644 index 6023d22fa..000000000 --- a/lib/mcp/tools/research/registerResearchChartsTool.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - platform: z - .string() - .describe("Chart platform: spotify, applemusic, tiktok, youtube, itunes, shazam, etc."), - country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), - interval: z.string().optional().describe("Time interval (e.g. daily, weekly)"), - type: z.string().optional().describe("Chart type (varies by platform)"), - latest: z - .boolean() - .optional() - .default(true) - .describe("Return only the latest chart (default: true)"), -}); - -/** - * Registers the "research_charts" tool on the MCP server. - * Returns global chart positions for a given platform. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchChartsTool(server: McpServer): void { - server.registerTool( - "get_chart_positions", - { - description: - "Get global chart positions for a platform — Spotify, Apple Music, TikTok, YouTube, iTunes, Shazam, etc. " + - "NOT artist-scoped. Returns ranked entries with track/artist info.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - if (!/^[a-zA-Z0-9]+$/.test(args.platform)) { - return getToolResultError("Invalid platform: must be alphanumeric with no slashes"); - } - - const queryParams: Record = {}; - - if (args.country) queryParams.country_code = args.country; - if (args.interval) queryParams.interval = args.interval; - if (args.type) queryParams.type = args.type; - queryParams.latest = String(args.latest ?? true); - - const result = await proxyToChartmetric(`/charts/${args.platform}`, queryParams); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch charts", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchCitiesTool.ts b/lib/mcp/tools/research/registerResearchCitiesTool.ts deleted file mode 100644 index 720a92933..000000000 --- a/lib/mcp/tools/research/registerResearchCitiesTool.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_cities" tool on the MCP server. - * Returns the top cities where an artist's fans listen, ranked by listener count. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchCitiesTool(server: McpServer): void { - server.registerTool( - "get_artist_cities", - { - description: - "Get the top cities where an artist's fans listen, ranked by listener concentration. " + - "Shows city name, country, and listener count.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/where-people-listen`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - - const raw = - ( - result.data as { - cities?: Record>; - } - )?.cities || {}; - - const cities = Object.entries(raw) - .map(([name, points]) => ({ - name, - country: points[points.length - 1]?.code2 || "", - listeners: points[points.length - 1]?.listeners || 0, - })) - .sort((a, b) => b.listeners - a.listeners); - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ cities }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch cities data", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchCuratorTool.ts b/lib/mcp/tools/research/registerResearchCuratorTool.ts deleted file mode 100644 index 93de2a184..000000000 --- a/lib/mcp/tools/research/registerResearchCuratorTool.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; - -const schema = z.object({ - platform: z.string().describe("Streaming platform (e.g. spotify)"), - id: z.string().describe("Curator ID"), -}); - -/** - * Registers the "research_curator" tool on the MCP server. - * Returns a curator profile — who curates a playlist, their other playlists, and follower reach. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchCuratorTool(server: McpServer): void { - server.registerTool( - "get_curator_info", - { - description: - "Get curator profile — who curates a playlist, their other playlists, and follower reach.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - if (!VALID_PLATFORMS.includes(args.platform)) { - return getToolResultError( - `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, - ); - } - - const result = await proxyToChartmetric(`/curator/${args.platform}/${args.id}`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch curator", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchDiscoverTool.ts b/lib/mcp/tools/research/registerResearchDiscoverTool.ts deleted file mode 100644 index c43dff134..000000000 --- a/lib/mcp/tools/research/registerResearchDiscoverTool.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), - genre: z.number().optional().describe("Genre tag ID from research_genres"), - sp_monthly_listeners_min: z.number().optional().describe("Minimum Spotify monthly listeners"), - sp_monthly_listeners_max: z.number().optional().describe("Maximum Spotify monthly listeners"), - sort: z.string().optional().describe("Sort column (e.g. sp_monthly_listeners, sp_followers)"), - limit: z - .number() - .optional() - .default(20) - .describe("Maximum number of artists to return (default: 20)"), -}); - -/** - * Registers the "research_discover" tool on the MCP server. - * Discovers artists by criteria — country, genre, listener count, and growth rate. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchDiscoverTool(server: McpServer): void { - server.registerTool( - "discover_artists", - { - description: - "Discover artists by criteria — filter by country, genre, listener count, follower count, and growth rate.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const queryParams: Record = {}; - - if (args.country) queryParams.code2 = args.country; - if (args.genre !== undefined) queryParams.tagId = String(args.genre); - if (args.sp_monthly_listeners_min !== undefined) { - queryParams.sp_monthly_listeners_min = String(args.sp_monthly_listeners_min); - } - if (args.sp_monthly_listeners_max !== undefined) { - queryParams.sp_monthly_listeners_max = String(args.sp_monthly_listeners_max); - } - if (args.sort) queryParams.sortColumn = args.sort; - if (args.limit) queryParams.limit = String(args.limit); - - const result = await proxyToChartmetric("/artist/list/filter", queryParams); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to discover artists", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchEnrichTool.ts b/lib/mcp/tools/research/registerResearchEnrichTool.ts deleted file mode 100644 index b296a3266..000000000 --- a/lib/mcp/tools/research/registerResearchEnrichTool.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { enrichEntity } from "@/lib/parallel/enrichEntity"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - input: z.string().describe("What to research"), - schema: z - .record(z.string(), z.unknown()) - .describe("JSON schema defining the output fields to extract"), - processor: z - .enum(["base", "core", "ultra"]) - .optional() - .default("base") - .describe("Processing tier: base (fast), core (balanced), ultra (comprehensive)"), -}); - -/** - * Registers the "research_enrich" tool on the MCP server. - * Enriches an entity with structured data from web research using Parallel's task API. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchEnrichTool(server: McpServer): void { - server.registerTool( - "enrich_entity", - { - description: - "Get structured data about any entity from web research. " + - "Provide a description and a JSON schema defining what fields to extract. " + - "Returns typed data with citations. " + - "Use processor 'base' for fast results, 'core' for balanced, 'ultra' for comprehensive.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const result = await enrichEntity( - args.input, - args.schema as Record, - args.processor ?? "base", - ); - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to enrich entity", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchExtractTool.ts b/lib/mcp/tools/research/registerResearchExtractTool.ts deleted file mode 100644 index 8f5210ebf..000000000 --- a/lib/mcp/tools/research/registerResearchExtractTool.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { extractUrl } from "@/lib/parallel/extractUrl"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - urls: z.array(z.string()).max(10).describe("URLs to extract content from (max 10)"), - objective: z.string().optional().describe("What information to focus the extraction on"), - full_content: z - .boolean() - .optional() - .describe("Return full page content instead of focused excerpts"), -}); - -/** - * Registers the "research_extract" tool on the MCP server. - * Extracts clean markdown content from public URLs using Parallel's extract API. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchExtractTool(server: McpServer): void { - server.registerTool( - "extract_url_content", - { - description: - "Extract clean markdown content from one or more public URLs. " + - "Handles JavaScript-heavy pages and PDFs. " + - "Pass an objective to focus the extraction on specific information.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const result = await extractUrl(args.urls, args.objective, args.full_content ?? false); - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to extract URL content", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchFestivalsTool.ts b/lib/mcp/tools/research/registerResearchFestivalsTool.ts deleted file mode 100644 index d57af48d3..000000000 --- a/lib/mcp/tools/research/registerResearchFestivalsTool.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({}); - -/** - * Registers the "research_festivals" tool on the MCP server. - * Lists music festivals. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchFestivalsTool(server: McpServer): void { - server.registerTool( - "get_festivals", - { - description: "List music festivals.", - inputSchema: schema, - }, - async (_args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const result = await proxyToChartmetric("/festival/list"); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch festivals", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchGenresTool.ts b/lib/mcp/tools/research/registerResearchGenresTool.ts deleted file mode 100644 index f239d5496..000000000 --- a/lib/mcp/tools/research/registerResearchGenresTool.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({}); - -/** - * Registers the "research_genres" tool on the MCP server. - * Lists all available genre IDs and names for use with the discover tool. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchGenresTool(server: McpServer): void { - server.registerTool( - "get_genres", - { - description: - "List all available genre IDs and names. Use these IDs with the research_discover tool.", - inputSchema: schema, - }, - async (_args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const result = await proxyToChartmetric("/genres"); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch genres", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchInsightsTool.ts b/lib/mcp/tools/research/registerResearchInsightsTool.ts deleted file mode 100644 index 1590ffefd..000000000 --- a/lib/mcp/tools/research/registerResearchInsightsTool.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_insights" tool on the MCP server. - * Returns AI-generated insights about an artist — trends, milestones, and observations. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchInsightsTool(server: McpServer): void { - server.registerTool( - "get_artist_insights", - { - description: - "Get AI-generated insights about an artist — automatically surfaced trends, milestones, and observations.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/noteworthy-insights`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - const data = result.data; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ - insights: Array.isArray(data) ? data : [], - }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch insights", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts deleted file mode 100644 index 08e1a268b..000000000 --- a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_instagram_posts" tool on the MCP server. - * Returns an artist's top Instagram posts and reels sorted by engagement. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchInstagramPostsTool(server: McpServer): void { - server.registerTool( - "get_artist_instagram_posts", - { - description: "Get an artist's top Instagram posts and reels sorted by engagement.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric( - `/SNS/deepSocial/cm_artist/${resolved.id}/instagram`, - ); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch Instagram posts", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchLookupTool.ts b/lib/mcp/tools/research/registerResearchLookupTool.ts deleted file mode 100644 index b9f391783..000000000 --- a/lib/mcp/tools/research/registerResearchLookupTool.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - url: z.string().describe("Spotify URL or platform ID"), -}); - -/** - * Registers the "research_lookup" tool on the MCP server. - * Looks up an artist by a Spotify URL or platform ID and returns the artist profile. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchLookupTool(server: McpServer): void { - server.registerTool( - "lookup_artist_by_url", - { - description: "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const spotifyId = args.url.split("/").pop()?.split("?")[0]; - - if (!spotifyId) { - return getToolResultError("Could not extract Spotify ID from URL"); - } - - const result = await proxyToChartmetric(`/artist/spotify/${spotifyId}/get-ids`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to look up artist", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchMetricsTool.ts b/lib/mcp/tools/research/registerResearchMetricsTool.ts deleted file mode 100644 index c83026339..000000000 --- a/lib/mcp/tools/research/registerResearchMetricsTool.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), - source: z - .string() - .describe( - "Platform: spotify, instagram, tiktok, youtube_channel, soundcloud, deezer, twitter, facebook, etc.", - ), -}); - -/** - * Registers the "research_metrics" tool on the MCP server. - * Fetches streaming and social metrics for an artist on a specific platform. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchMetricsTool(server: McpServer): void { - server.registerTool( - "get_artist_metrics", - { - description: - "Get streaming and social metrics for an artist on a specific platform. " + - "Supports 14 platforms including spotify, instagram, tiktok, youtube_channel, " + - "soundcloud, deezer, twitter, facebook.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/stat/${args.source}`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch metrics", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchMilestonesTool.ts b/lib/mcp/tools/research/registerResearchMilestonesTool.ts deleted file mode 100644 index a7195c2a8..000000000 --- a/lib/mcp/tools/research/registerResearchMilestonesTool.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_milestones" tool on the MCP server. - * Returns an artist's activity feed — playlist adds, chart entries, and notable events. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchMilestonesTool(server: McpServer): void { - server.registerTool( - "get_artist_milestones", - { - description: - "Get an artist's activity feed — playlist adds, chart entries, and notable events. " + - "Each milestone includes a date, summary, platform, track name, and star rating.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/milestones`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - const milestones = (result.data as Record)?.insights || []; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ milestones }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch milestones", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchPeopleTool.ts b/lib/mcp/tools/research/registerResearchPeopleTool.ts deleted file mode 100644 index 066678adc..000000000 --- a/lib/mcp/tools/research/registerResearchPeopleTool.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { searchPeople } from "@/lib/exa/searchPeople"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - query: z.string().describe("Search query for people"), - num_results: z - .number() - .optional() - .default(10) - .describe("Number of results to return (default: 10)"), -}); - -/** - * Registers the "research_people" tool on the MCP server. - * Searches for people in the music industry using Exa's people index. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchPeopleTool(server: McpServer): void { - server.registerTool( - "find_industry_people", - { - description: - "Search for people in the music industry — artists, managers, A&R reps, producers. " + - "Returns profiles with LinkedIn data and summaries.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const result = await searchPeople(args.query, args.num_results ?? 10); - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to search for people", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchPlaylistTool.ts b/lib/mcp/tools/research/registerResearchPlaylistTool.ts deleted file mode 100644 index 6c3423dc7..000000000 --- a/lib/mcp/tools/research/registerResearchPlaylistTool.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; - -const schema = z.object({ - platform: z - .enum(["spotify", "applemusic", "deezer", "amazon", "youtube"]) - .describe("Streaming platform"), - id: z.string().describe("Playlist ID or name to search for"), -}); - -/** - * Registers the "research_playlist" tool on the MCP server. - * Returns metadata for a single playlist — name, description, follower count, and curator info. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchPlaylistTool(server: McpServer): void { - server.registerTool( - "get_playlist_info", - { - description: - "Get playlist metadata — name, description, follower count, track count, and curator info.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - if (!VALID_PLATFORMS.includes(args.platform)) { - return getToolResultError( - `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, - ); - } - - let numericId = args.id; - - if (!/^\d+$/.test(numericId)) { - const searchResult = await proxyToChartmetric("/search", { - q: numericId, - type: "playlists", - limit: "1", - }); - if (searchResult.status !== 200) { - return getToolResultError(`Request failed with status ${searchResult.status}`); - } - - const playlists = searchResult.data as Record[]; - if (!Array.isArray(playlists) || playlists.length === 0) { - return getToolResultError(`No playlist found for "${args.id}"`); - } - - numericId = String((playlists[0] as Record).id); - } - - const result = await proxyToChartmetric(`/playlist/${args.platform}/${numericId}`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch playlist", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts deleted file mode 100644 index 802976f32..000000000 --- a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), - platform: z - .string() - .optional() - .default("spotify") - .describe("Streaming platform (default: spotify)"), - status: z - .string() - .optional() - .default("current") - .describe("Playlist status: current or past (default: current)"), - editorial: z.boolean().optional().describe("Filter to editorial playlists only"), - limit: z - .number() - .optional() - .default(20) - .describe("Maximum number of playlists to return (default: 20)"), -}); - -/** - * Registers the "research_playlists" tool on the MCP server. - * Returns playlist placements for an artist — editorial, algorithmic, and indie. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchPlaylistsTool(server: McpServer): void { - server.registerTool( - "get_artist_playlists", - { - description: - "Get an artist's playlist placements — editorial, algorithmic, and indie playlists. " + - "Shows playlist name, follower count, track name, and curator.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const platform = args.platform ?? "spotify"; - if (!VALID_PLATFORMS.includes(platform)) { - return getToolResultError( - `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, - ); - } - const status = args.status ?? "current"; - - const queryParams: Record = {}; - if (args.limit) queryParams.limit = String(args.limit); - - if (args.editorial !== undefined) { - queryParams.editorial = String(args.editorial); - } else { - queryParams.editorial = "true"; - queryParams.indie = "true"; - queryParams.majorCurator = "true"; - queryParams.popularIndie = "true"; - } - - const result = await proxyToChartmetric( - `/artist/${resolved.id}/${platform}/${status}/playlists`, - queryParams, - ); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - - const data = result.data; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ - placements: Array.isArray(data) ? data : [], - }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch playlists", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchRadioTool.ts b/lib/mcp/tools/research/registerResearchRadioTool.ts deleted file mode 100644 index f8c4a9691..000000000 --- a/lib/mcp/tools/research/registerResearchRadioTool.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({}); - -/** - * Registers the "research_radio" tool on the MCP server. - * Returns the list of radio stations tracked by Chartmetric. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchRadioTool(server: McpServer): void { - server.registerTool( - "get_radio_stations", - { - description: - "List radio stations tracked by Chartmetric. " + - "Returns station names, formats, and markets.", - inputSchema: schema, - }, - async (_args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const result = await proxyToChartmetric("/radio/station-list"); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - const stations = Array.isArray(result.data) ? result.data : []; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ stations }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch radio stations", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchRankTool.ts b/lib/mcp/tools/research/registerResearchRankTool.ts deleted file mode 100644 index 93f567e35..000000000 --- a/lib/mcp/tools/research/registerResearchRankTool.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_rank" tool on the MCP server. - * Returns the artist's global Chartmetric ranking. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchRankTool(server: McpServer): void { - server.registerTool( - "get_artist_rank", - { - description: - "Get an artist's global Chartmetric ranking. " + "Returns a single integer rank value.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/artist-rank`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - const rank = (result.data as Record)?.artist_rank || null; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ rank }); - } catch (error) { - return getToolResultError(error instanceof Error ? error.message : "Failed to fetch rank"); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchSearchTool.ts b/lib/mcp/tools/research/registerResearchSearchTool.ts deleted file mode 100644 index f1e700b91..000000000 --- a/lib/mcp/tools/research/registerResearchSearchTool.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - query: z.string().describe("Search query — artist name, track title, or keyword"), - type: z - .string() - .optional() - .describe("Result type: artists, tracks, or albums (default: artists)"), - limit: z.string().optional().describe("Max results to return (default: 10)"), -}); - -/** - * Registers the "research_search" tool on the MCP server. - * Searches Chartmetric for artists, tracks, or albums by keyword. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchSearchTool(server: McpServer): void { - server.registerTool( - "search_artists", - { - description: - "Search for music artists, tracks, or albums by keyword. Returns matching results with profile summaries.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const result = await proxyToChartmetric("/search", { - q: args.query, - type: args.type || "artists", - limit: args.limit || "10", - }); - - if (result.status !== 200) { - return getToolResultError(`Search failed with status ${result.status}`); - } - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - - const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; - const results = data?.artists || data?.tracks || data?.albums || []; - - return getToolResultSuccess({ results }); - } catch (error) { - return getToolResultError(error instanceof Error ? error.message : "Search failed"); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchSimilarTool.ts b/lib/mcp/tools/research/registerResearchSimilarTool.ts deleted file mode 100644 index 90d937857..000000000 --- a/lib/mcp/tools/research/registerResearchSimilarTool.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), - audience: z.string().optional().describe("Audience overlap weight: high, medium, or low"), - genre: z.string().optional().describe("Genre similarity weight: high, medium, or low"), - mood: z.string().optional().describe("Mood similarity weight: high, medium, or low"), - musicality: z.string().optional().describe("Musicality similarity weight: high, medium, or low"), - limit: z - .number() - .optional() - .default(10) - .describe("Maximum number of similar artists to return (default: 10)"), -}); - -/** - * Registers the "research_similar" tool on the MCP server. - * Finds similar artists using audience overlap, genre, mood, and musicality weights. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchSimilarTool(server: McpServer): void { - server.registerTool( - "get_similar_artists", - { - description: - "Find similar artists based on audience overlap, genre, mood, and musicality. " + - "Returns career stage, momentum, and streaming numbers for each. " + - "Use for competitive analysis and collaboration discovery.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const hasConfigParams = CONFIG_PARAMS.some(p => args[p] !== undefined); - - const queryParams: Record = {}; - for (const key of CONFIG_PARAMS) { - if (args[key]) queryParams[key] = args[key]; - } - if (args.limit) queryParams.limit = String(args.limit); - - const path = hasConfigParams - ? `/artist/${resolved.id}/similar-artists/by-configurations` - : `/artist/${resolved.id}/relatedartists`; - - const result = await proxyToChartmetric(path, queryParams); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - - const data = result.data as Record; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ - artists: Array.isArray(data) ? data : data?.data || [], - total: Array.isArray(data) ? undefined : data?.total, - }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to find similar artists", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts deleted file mode 100644 index 764e9d5d5..000000000 --- a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import { resolveTrack } from "@/lib/research/resolveTrack"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; - -const schema = z.object({ - id: z.string().optional().describe("Chartmetric track ID. Provide this or q."), - q: z.string().optional().describe("Track name to search for. Provide this or id."), - artist: z - .string() - .optional() - .describe("Artist name — improves track search accuracy when using q."), - platform: z - .string() - .optional() - .default("spotify") - .describe("Streaming platform (default: spotify)"), - status: z - .string() - .optional() - .default("current") - .describe("Playlist status: current or past (default: current)"), - editorial: z.boolean().optional().describe("Filter to editorial playlists only"), - limit: z - .number() - .optional() - .default(10) - .describe("Maximum number of playlists to return (default: 10)"), -}); - -/** - * Registers the "get_track_playlists" tool on the MCP server. - * Returns playlist placements for a specific track. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchTrackPlaylistsTool(server: McpServer): void { - server.registerTool( - "get_track_playlists", - { - description: - "Get playlists featuring a specific track. " + - "Use this to find which editorial, indie, and algorithmic playlists a particular song is on. " + - "Returns playlist name, cover image, follower count, and curator.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - if (!args.id && !args.q) { - return getToolResultError("Either id or q parameter is required"); - } - - try { - let trackId = args.id; - - if (!trackId) { - const resolved = await resolveTrack(args.q!, args.artist); - if (resolved.error) { - return getToolResultError(resolved.error); - } - trackId = resolved.id; - } - - const platform = args.platform ?? "spotify"; - if (!VALID_PLATFORMS.includes(platform)) { - return getToolResultError( - `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, - ); - } - const status = args.status ?? "current"; - - const queryParams: Record = {}; - if (args.limit) queryParams.limit = String(args.limit); - - if (args.editorial !== undefined) { - queryParams.editorial = String(args.editorial); - } else { - queryParams.editorial = "true"; - queryParams.indie = "true"; - queryParams.majorCurator = "true"; - queryParams.popularIndie = "true"; - } - - const result = await proxyToChartmetric( - `/track/${trackId}/${platform}/${status}/playlists`, - queryParams, - ); - - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - - return getToolResultSuccess({ - placements: Array.isArray(result.data) ? result.data : [], - }); - } catch (err) { - return getToolResultError( - err instanceof Error ? err.message : "Failed to fetch track playlists", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchTrackTool.ts b/lib/mcp/tools/research/registerResearchTrackTool.ts deleted file mode 100644 index 6e3b9653c..000000000 --- a/lib/mcp/tools/research/registerResearchTrackTool.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - q: z.string().describe("Track name or Spotify URL"), -}); - -/** - * Registers the "research_track" tool on the MCP server. - * Searches for a track by name or URL and returns its metadata. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchTrackTool(server: McpServer): void { - server.registerTool( - "get_track_info", - { - description: "Get track metadata — title, artist, album, release date, and popularity.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const searchResult = await proxyToChartmetric("/search", { - q: args.q, - type: "tracks", - limit: "1", - }); - if (searchResult.status !== 200) { - return getToolResultError(`Request failed with status ${searchResult.status}`); - } - - const searchData = searchResult.data as { tracks?: Array<{ id: number }> }; - const tracks = searchData?.tracks; - if (!Array.isArray(tracks) || tracks.length === 0) { - return getToolResultError(`No track found for "${args.q}"`); - } - - const trackId = tracks[0].id; - const result = await proxyToChartmetric(`/track/${trackId}`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError(error instanceof Error ? error.message : "Failed to fetch track"); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchTracksTool.ts b/lib/mcp/tools/research/registerResearchTracksTool.ts deleted file mode 100644 index fdc2b9b3a..000000000 --- a/lib/mcp/tools/research/registerResearchTracksTool.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_tracks" tool on the MCP server. - * Returns all tracks by an artist with popularity data. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchTracksTool(server: McpServer): void { - server.registerTool( - "get_artist_tracks", - { - description: - "Get all tracks by an artist with popularity data. Accepts artist name. For Spotify top 10 tracks with preview URLs, use get_spotify_artist_top_tracks instead.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - const data = result.data; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ - tracks: Array.isArray(data) ? data : [], - }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch tracks", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchUrlsTool.ts b/lib/mcp/tools/research/registerResearchUrlsTool.ts deleted file mode 100644 index fa9d99068..000000000 --- a/lib/mcp/tools/research/registerResearchUrlsTool.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_urls" tool on the MCP server. - * Returns all social and streaming URLs for an artist. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchUrlsTool(server: McpServer): void { - server.registerTool( - "get_artist_urls", - { - description: - "Get all known social and streaming URLs for any artist by name — Spotify, Instagram, TikTok, YouTube, Twitter, SoundCloud, and more. For socials connected to a Recoup artist account, use get_artist_socials instead.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/urls`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError(error instanceof Error ? error.message : "Failed to fetch URLs"); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchVenuesTool.ts b/lib/mcp/tools/research/registerResearchVenuesTool.ts deleted file mode 100644 index 22c6d7dd1..000000000 --- a/lib/mcp/tools/research/registerResearchVenuesTool.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { deductCredits } from "@/lib/credits/deductCredits"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_venues" tool on the MCP server. - * Returns venues the artist has performed at, including capacity and location. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchVenuesTool(server: McpServer): void { - server.registerTool( - "get_artist_venues", - { - description: - "Get venues an artist has performed at. " + - "Includes venue name, capacity, city, country, and event history.", - inputSchema: schema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, - }); - if (error) return getToolResultError(error); - if (!accountId) return getToolResultError("Failed to resolve account ID"); - - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}/venues`); - if (result.status !== 200) { - return getToolResultError(`Request failed with status ${result.status}`); - } - const venues = Array.isArray(result.data) ? result.data : []; - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } - return getToolResultSuccess({ venues }); - } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch venues", - ); - } - }, - ); -} From 87a8cdc19187fb3229797ba085306d9f7c0316a2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 15:18:07 -0500 Subject: [PATCH 10/28] refactor: replace handleArtistResearch orchestrator with small composable helpers Ports 15 artist-scoped research handlers off the template-method orchestrator (handleArtistResearch) onto three focused helpers: requireArtist, getArtistResearch, and jsonSuccess/jsonError. Each handler now reads top-to-bottom in ~10 lines and explicitly names its response key (no more magic { status } spreading collision). Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/networking/__tests__/jsonResponse.test.ts | 29 +++++ lib/networking/jsonResponse.ts | 26 +++++ .../__tests__/getArtistResearch.test.ts | 100 ++++++++++++++++++ lib/research/__tests__/requireArtist.test.ts | 45 ++++++++ lib/research/getArtistResearch.ts | 44 ++++++++ lib/research/getResearchAlbumsHandler.ts | 22 ++-- lib/research/getResearchAudienceHandler.ts | 22 +++- lib/research/getResearchCareerHandler.ts | 22 ++-- lib/research/getResearchCitiesHandler.ts | 47 ++++---- lib/research/getResearchInsightsHandler.ts | 22 ++-- .../getResearchInstagramPostsHandler.ts | 22 +++- lib/research/getResearchMetricsHandler.ts | 35 +++--- lib/research/getResearchMilestonesHandler.ts | 24 +++-- lib/research/getResearchPlaylistsHandler.ts | 87 +++++++-------- lib/research/getResearchProfileHandler.ts | 22 +++- lib/research/getResearchRankHandler.ts | 24 +++-- lib/research/getResearchSimilarHandler.ts | 48 +++++---- lib/research/getResearchTracksHandler.ts | 22 ++-- lib/research/getResearchUrlsHandler.ts | 34 +++--- lib/research/getResearchVenuesHandler.ts | 22 ++-- lib/research/handleArtistResearch.ts | 73 ------------- lib/research/requireArtist.ts | 22 ++++ 22 files changed, 579 insertions(+), 235 deletions(-) create mode 100644 lib/networking/__tests__/jsonResponse.test.ts create mode 100644 lib/networking/jsonResponse.ts create mode 100644 lib/research/__tests__/getArtistResearch.test.ts create mode 100644 lib/research/__tests__/requireArtist.test.ts create mode 100644 lib/research/getArtistResearch.ts delete mode 100644 lib/research/handleArtistResearch.ts create mode 100644 lib/research/requireArtist.ts diff --git a/lib/networking/__tests__/jsonResponse.test.ts b/lib/networking/__tests__/jsonResponse.test.ts new file mode 100644 index 000000000..b3178da18 --- /dev/null +++ b/lib/networking/__tests__/jsonResponse.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +import { jsonSuccess, jsonError } from "../jsonResponse"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); + +describe("jsonSuccess", () => { + it("returns 200 with { status: 'success', ...body } and CORS headers", async () => { + const res = jsonSuccess({ albums: ["a"] }); + expect(res).toBeInstanceOf(NextResponse); + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + const body = await res.json(); + expect(body).toEqual({ status: "success", albums: ["a"] }); + }); +}); + +describe("jsonError", () => { + it("returns the given status with { status: 'error', error } and CORS headers", async () => { + const res = jsonError(404, "Not found"); + expect(res.status).toBe(404); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + const body = await res.json(); + expect(body).toEqual({ status: "error", error: "Not found" }); + }); +}); diff --git a/lib/networking/jsonResponse.ts b/lib/networking/jsonResponse.ts new file mode 100644 index 000000000..76de1c9ca --- /dev/null +++ b/lib/networking/jsonResponse.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Wraps a success payload in the standard `{ status: "success", ...body }` + * envelope with CORS headers applied. + * + * @param body - Fields to spread at the root of the response. + */ +export function jsonSuccess(body: Record): NextResponse { + return NextResponse.json( + { status: "success", ...body }, + { status: 200, headers: getCorsHeaders() }, + ); +} + +/** + * Returns an error response with the standard `{ status: "error", error }` + * envelope and CORS headers. + * + * @param status - HTTP status code + * @param error - Human-readable error message + */ +export function jsonError(status: number, error: string): NextResponse { + return NextResponse.json({ status: "error", error }, { status, headers: getCorsHeaders() }); +} diff --git a/lib/research/__tests__/getArtistResearch.test.ts b/lib/research/__tests__/getArtistResearch.test.ts new file mode 100644 index 000000000..8803608df --- /dev/null +++ b/lib/research/__tests__/getArtistResearch.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { getArtistResearch } from "../getArtistResearch"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +vi.mock("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getArtistResearch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns { error, status: 404 } when artist cannot be resolved", async () => { + vi.mocked(resolveArtist).mockResolvedValue({ error: "Artist not found" } as never); + + const result = await getArtistResearch({ + artist: "Unknown", + accountId: "acc_1", + path: id => `/artist/${id}/albums`, + }); + + expect(result).toEqual({ error: "Artist not found", status: 404 }); + expect(proxyToChartmetric).not.toHaveBeenCalled(); + expect(deductCredits).not.toHaveBeenCalled(); + }); + + it("proxies to the built path and returns { data } on success", async () => { + vi.mocked(resolveArtist).mockResolvedValue({ id: 42 } as never); + vi.mocked(proxyToChartmetric).mockResolvedValue({ + status: 200, + data: [{ name: "a" }], + } as never); + vi.mocked(deductCredits).mockResolvedValue(undefined as never); + + const result = await getArtistResearch({ + artist: "Drake", + accountId: "acc_1", + path: id => `/artist/${id}/albums`, + }); + + expect(proxyToChartmetric).toHaveBeenCalledWith("/artist/42/albums", undefined); + expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5 }); + expect(result).toEqual({ data: [{ name: "a" }] }); + }); + + it("forwards query params to proxyToChartmetric", async () => { + vi.mocked(resolveArtist).mockResolvedValue({ id: 7 } as never); + vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + + await getArtistResearch({ + artist: "X", + accountId: "acc_1", + path: id => `/artist/${id}/playlists`, + query: { limit: "10", platform: "spotify" }, + }); + + expect(proxyToChartmetric).toHaveBeenCalledWith("/artist/7/playlists", { + limit: "10", + platform: "spotify", + }); + }); + + it("returns the upstream status as an error when proxy is non-200", async () => { + vi.mocked(resolveArtist).mockResolvedValue({ id: 1 } as never); + vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 502, data: null } as never); + + const result = await getArtistResearch({ + artist: "X", + accountId: "acc_1", + path: id => `/artist/${id}/x`, + }); + + expect(result).toEqual({ error: "Request failed with status 502", status: 502 }); + expect(deductCredits).not.toHaveBeenCalled(); + }); + + it("swallows credit-deduction failures and still returns data", async () => { + vi.mocked(resolveArtist).mockResolvedValue({ id: 1 } as never); + vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(deductCredits).mockRejectedValue(new Error("DB down")); + + const result = await getArtistResearch({ + artist: "X", + accountId: "acc_1", + path: () => "/x", + }); + + expect(result).toEqual({ data: "ok" }); + }); +}); diff --git a/lib/research/__tests__/requireArtist.test.ts b/lib/research/__tests__/requireArtist.test.ts new file mode 100644 index 000000000..d30a07f50 --- /dev/null +++ b/lib/research/__tests__/requireArtist.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { requireArtist } from "../requireArtist"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("requireArtist", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth response when validateAuthContext fails", async () => { + const unauthorized = NextResponse.json({ error: "x" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(unauthorized); + + const result = await requireArtist(new NextRequest("http://x/?artist=drake")); + + expect(result).toBe(unauthorized); + }); + + it("returns a 400 response when artist is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + + const result = await requireArtist(new NextRequest("http://x/")); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("artist parameter is required"); + } + }); + + it("returns accountId and artist on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + + const result = await requireArtist(new NextRequest("http://x/?artist=Drake")); + + expect(result).toEqual({ accountId: "acc_1", artist: "Drake" }); + }); +}); diff --git a/lib/research/getArtistResearch.ts b/lib/research/getArtistResearch.ts new file mode 100644 index 000000000..7f4a3c7c2 --- /dev/null +++ b/lib/research/getArtistResearch.ts @@ -0,0 +1,44 @@ +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +export type GetArtistResearchParams = { + artist: string; + accountId: string; + path: (cmId: number) => string; + query?: Record; + /** Credits to charge on success. Defaults to 5. */ + credits?: number; +}; + +export type GetArtistResearchResult = { data: unknown } | { error: string; status: number }; + +/** + * Resolves an artist to a Chartmetric ID, proxies to the built upstream path, + * and deducts credits on success. Credit deduction errors are non-fatal — + * the fetched data is still returned so transient billing failures don't + * block read endpoints. + * + * @returns `{ data }` on success, `{ error, status }` on failure. + */ +export async function getArtistResearch( + params: GetArtistResearchParams, +): Promise { + const { artist, accountId, path, query, credits = 5 } = params; + + const resolved = await resolveArtist(artist); + if (resolved.error) return { error: resolved.error, status: 404 }; + + const result = await proxyToChartmetric(path(resolved.id), query); + if (result.status !== 200) { + return { error: `Request failed with status ${result.status}`, status: result.status }; + } + + try { + await deductCredits({ accountId, creditsToDeduct: credits }); + } catch (error) { + console.error("[research] credit deduction failed:", error); + } + + return { data: result.data }; +} diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 97b9a5cf2..68bee0281 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/albums @@ -11,10 +14,15 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchAlbumsHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/albums`, - undefined, - data => ({ albums: Array.isArray(data) ? data : [] }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/albums`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ albums: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts index 3615b6775..0620062ad 100644 --- a/lib/research/getResearchAudienceHandler.ts +++ b/lib/research/getResearchAudienceHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/audience @@ -12,8 +15,23 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchAudienceHandler(request: NextRequest) { + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + const { searchParams } = new URL(request.url); const platform = searchParams.get("platform") || "instagram"; - return handleArtistResearch(request, cmId => `/artist/${cmId}/${platform}-audience-stats`); + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/${platform}-audience-stats`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return jsonSuccess(body); } diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts index df8b90236..20d6c00ba 100644 --- a/lib/research/getResearchCareerHandler.ts +++ b/lib/research/getResearchCareerHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/career @@ -11,10 +14,15 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchCareerHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/career`, - undefined, - data => ({ career: Array.isArray(data) ? data : [] }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/career`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ career: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts index d469cacc3..959b98401 100644 --- a/lib/research/getResearchCitiesHandler.ts +++ b/lib/research/getResearchCitiesHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/cities @@ -11,23 +14,27 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchCitiesHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/where-people-listen`, - undefined, - data => { - const raw = - (data as { cities?: Record> }) - ?.cities || {}; - return { - cities: Object.entries(raw) - .map(([name, points]) => ({ - name, - country: points[points.length - 1]?.code2 || "", - listeners: points[points.length - 1]?.listeners || 0, - })) - .sort((a, b) => b.listeners - a.listeners), - }; - }, - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/where-people-listen`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + + const raw = + (result.data as { cities?: Record> }) + ?.cities || {}; + const cities = Object.entries(raw) + .map(([name, points]) => ({ + name, + country: points[points.length - 1]?.code2 || "", + listeners: points[points.length - 1]?.listeners || 0, + })) + .sort((a, b) => b.listeners - a.listeners); + + return jsonSuccess({ cities }); } diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts index b0cae4a58..a6547d85c 100644 --- a/lib/research/getResearchInsightsHandler.ts +++ b/lib/research/getResearchInsightsHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/insights @@ -12,10 +15,15 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchInsightsHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/noteworthy-insights`, - undefined, - data => ({ insights: Array.isArray(data) ? data : [] }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/noteworthy-insights`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ insights: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index 93ca7bdc6..b9ab5868e 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/instagram-posts @@ -12,5 +15,20 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchInstagramPostsHandler(request: NextRequest) { - return handleArtistResearch(request, cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return jsonSuccess(body); } diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts index c5eb19de3..70deae767 100644 --- a/lib/research/getResearchMetricsHandler.ts +++ b/lib/research/getResearchMetricsHandler.ts @@ -1,6 +1,8 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { type NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/metrics @@ -17,10 +19,7 @@ export async function getResearchMetricsHandler(request: NextRequest) { const source = searchParams.get("source"); if (!source) { - return NextResponse.json( - { status: "error", error: "source parameter is required" }, - { status: 400, headers: getCorsHeaders() }, - ); + return jsonError(400, "source parameter is required"); } const VALID_SOURCES = [ @@ -41,11 +40,23 @@ export async function getResearchMetricsHandler(request: NextRequest) { ]; if (!VALID_SOURCES.includes(source)) { - return NextResponse.json( - { status: "error", error: `Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}` }, - { status: 400, headers: getCorsHeaders() }, - ); + return jsonError(400, `Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}`); } - return handleArtistResearch(request, cmId => `/artist/${cmId}/stat/${source}`); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/stat/${source}`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return jsonSuccess(body); } diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts index 10eb845cf..cdb9b7fed 100644 --- a/lib/research/getResearchMilestonesHandler.ts +++ b/lib/research/getResearchMilestonesHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/milestones @@ -11,10 +14,17 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchMilestonesHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/milestones`, - undefined, - data => ({ milestones: (data as Record)?.insights || [] }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/milestones`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ + milestones: (result.data as Record)?.insights || [], + }); } diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index 04b44185c..90a2acbbd 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -1,6 +1,8 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { type NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * Playlists handler — returns playlists featuring an artist. Supports `?platform=`, `?status=`, `?limit=`, `?sort=`, `?since=`, and playlist-type filters. @@ -15,47 +17,48 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; if (!VALID_PLATFORMS.includes(platform)) { - return NextResponse.json( - { status: "error", error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}` }, - { status: 400, headers: getCorsHeaders() }, - ); + return jsonError(400, `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`); } - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/${platform}/${status}/playlists`, - sp => { - const params: Record = {}; - const limit = sp.get("limit"); - if (limit) params.limit = limit; - const sort = sp.get("sort"); - if (sort) params.sortColumn = sort; - const since = sp.get("since"); - if (since) params.since = since; + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; - const hasFilters = - sp.get("editorial") || - sp.get("indie") || - sp.get("majorCurator") || - sp.get("popularIndie") || - sp.get("personalized") || - sp.get("chart"); - if (hasFilters) { - if (sp.get("editorial")) params.editorial = sp.get("editorial")!; - if (sp.get("indie")) params.indie = sp.get("indie")!; - if (sp.get("majorCurator")) params.majorCurator = sp.get("majorCurator")!; - if (sp.get("popularIndie")) params.popularIndie = sp.get("popularIndie")!; - if (sp.get("personalized")) params.personalized = sp.get("personalized")!; - if (sp.get("chart")) params.chart = sp.get("chart")!; - } else { - params.editorial = "true"; - params.indie = "true"; - params.majorCurator = "true"; - params.popularIndie = "true"; - } + const query: Record = {}; + const limit = searchParams.get("limit"); + if (limit) query.limit = limit; + const sort = searchParams.get("sort"); + if (sort) query.sortColumn = sort; + const since = searchParams.get("since"); + if (since) query.since = since; - return params; - }, - data => ({ placements: Array.isArray(data) ? data : [] }), - ); + const hasFilters = + searchParams.get("editorial") || + searchParams.get("indie") || + searchParams.get("majorCurator") || + searchParams.get("popularIndie") || + searchParams.get("personalized") || + searchParams.get("chart"); + if (hasFilters) { + if (searchParams.get("editorial")) query.editorial = searchParams.get("editorial")!; + if (searchParams.get("indie")) query.indie = searchParams.get("indie")!; + if (searchParams.get("majorCurator")) query.majorCurator = searchParams.get("majorCurator")!; + if (searchParams.get("popularIndie")) query.popularIndie = searchParams.get("popularIndie")!; + if (searchParams.get("personalized")) query.personalized = searchParams.get("personalized")!; + if (searchParams.get("chart")) query.chart = searchParams.get("chart")!; + } else { + query.editorial = "true"; + query.indie = "true"; + query.majorCurator = "true"; + query.popularIndie = "true"; + } + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/${platform}/${status}/playlists`, + query, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ placements: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts index f09d1c53c..df9347d99 100644 --- a/lib/research/getResearchProfileHandler.ts +++ b/lib/research/getResearchProfileHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/profile @@ -11,5 +14,20 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchProfileHandler(request: NextRequest) { - return handleArtistResearch(request, cmId => `/artist/${cmId}`); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return jsonSuccess(body); } diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts index 4a68f6be4..45b219eb3 100644 --- a/lib/research/getResearchRankHandler.ts +++ b/lib/research/getResearchRankHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/rank @@ -10,10 +13,17 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchRankHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/artist-rank`, - undefined, - data => ({ rank: (data as Record)?.artist_rank || null }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/artist-rank`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ + rank: (result.data as Record)?.artist_rank || null, + }); } diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts index a50db87cc..a96666d5f 100644 --- a/lib/research/getResearchSimilarHandler.ts +++ b/lib/research/getResearchSimilarHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; @@ -15,22 +18,29 @@ const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; * @returns The JSON response. */ export async function getResearchSimilarHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/similar-artists/by-configurations`, - sp => { - const params: Record = {}; - for (const key of CONFIG_PARAMS) { - const val = sp.get(key); - params[key] = val || "medium"; - } - const limit = sp.get("limit"); - if (limit) params.limit = limit; - return params; - }, - data => ({ - artists: Array.isArray(data) ? data : (data as Record)?.data || [], - total: (data as Record)?.total, - }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const { searchParams } = new URL(request.url); + const query: Record = {}; + for (const key of CONFIG_PARAMS) { + const val = searchParams.get(key); + query[key] = val || "medium"; + } + const limit = searchParams.get("limit"); + if (limit) query.limit = limit; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/similar-artists/by-configurations`, + query, + }); + + if ("error" in result) return jsonError(result.status, result.error); + const data = result.data; + return jsonSuccess({ + artists: Array.isArray(data) ? data : (data as Record)?.data || [], + total: (data as Record)?.total, + }); } diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts index 8664017a9..778dbe4ec 100644 --- a/lib/research/getResearchTracksHandler.ts +++ b/lib/research/getResearchTracksHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/tracks @@ -11,10 +14,15 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchTracksHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/tracks`, - undefined, - data => ({ tracks: Array.isArray(data) ? data : [] }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/tracks`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ tracks: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts index d947e4621..8d5b3fb74 100644 --- a/lib/research/getResearchUrlsHandler.ts +++ b/lib/research/getResearchUrlsHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/urls @@ -12,17 +15,20 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchUrlsHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/urls`, - undefined, - data => ({ - urls: Array.isArray(data) - ? data - : Object.entries(data as Record).map(([domain, url]) => ({ - domain, - url, - })), - }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/urls`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + const data = result.data; + return jsonSuccess({ + urls: Array.isArray(data) + ? data + : Object.entries(data as Record).map(([domain, url]) => ({ domain, url })), + }); } diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts index be9e10770..a44192752 100644 --- a/lib/research/getResearchVenuesHandler.ts +++ b/lib/research/getResearchVenuesHandler.ts @@ -1,5 +1,8 @@ import { type NextRequest } from "next/server"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { NextResponse } from "next/server"; +import { requireArtist } from "@/lib/research/requireArtist"; +import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** * GET /api/research/venues @@ -10,10 +13,15 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @returns The JSON response. */ export async function getResearchVenuesHandler(request: NextRequest) { - return handleArtistResearch( - request, - cmId => `/artist/${cmId}/venues`, - undefined, - data => ({ venues: Array.isArray(data) ? data : [] }), - ); + const gate = await requireArtist(request); + if (gate instanceof NextResponse) return gate; + + const result = await getArtistResearch({ + artist: gate.artist, + accountId: gate.accountId, + path: cmId => `/artist/${cmId}/venues`, + }); + + if ("error" in result) return jsonError(result.status, result.error); + return jsonSuccess({ venues: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts deleted file mode 100644 index 630357dc3..000000000 --- a/lib/research/handleArtistResearch.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; - -/** - * Shared handler for artist-scoped research endpoints. Handles auth, artist resolution, credit deduction, and proxying. - * - * @param request - must include `artist` query param for Chartmetric resolution - * @param buildPath - maps resolved Chartmetric ID to API path - * @param getQueryParams - extracts additional query params from the request - * @param transformResponse - reshapes the proxy response data - * @returns JSON response with artist data or error - */ -export async function handleArtistResearch( - request: NextRequest, - buildPath: (cmId: number) => string, - getQueryParams?: (searchParams: URLSearchParams) => Record, - transformResponse?: (data: unknown) => unknown, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - const { searchParams } = new URL(request.url); - const artist = searchParams.get("artist"); - - if (!artist) { - return NextResponse.json( - { status: "error", error: "artist parameter is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const resolved = await resolveArtist(artist); - if (resolved.error) { - return NextResponse.json( - { status: "error", error: resolved.error }, - { status: 404, headers: getCorsHeaders() }, - ); - } - - const path = buildPath(resolved.id); - const queryParams = getQueryParams ? getQueryParams(searchParams) : undefined; - const result = await proxyToChartmetric(path, queryParams); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: `Request failed with status ${result.status}` }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — log but don't block - } - - const responseData = transformResponse ? transformResponse(result.data) : result.data; - - return NextResponse.json( - { - status: "success", - ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) - ? responseData - : { data: responseData }), - }, - { status: 200, headers: getCorsHeaders() }, - ); -} diff --git a/lib/research/requireArtist.ts b/lib/research/requireArtist.ts new file mode 100644 index 000000000..2f200d397 --- /dev/null +++ b/lib/research/requireArtist.ts @@ -0,0 +1,22 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { jsonError } from "@/lib/networking/jsonResponse"; + +/** + * Auth + `artist` query param gate for artist-scoped research endpoints. + * Returns a `NextResponse` to short-circuit on auth or validation failures, + * otherwise returns the authenticated `accountId` and the `artist` query value. + * + * @param request - The incoming HTTP request. + */ +export async function requireArtist( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const artist = new URL(request.url).searchParams.get("artist"); + if (!artist) return jsonError(400, "artist parameter is required"); + + return { accountId: authResult.accountId, artist }; +} From d24785d10c95fd00bb7bbbe311216a3b4a7e42ae Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 15:22:14 -0500 Subject: [PATCH 11/28] =?UTF-8?q?refactor:=20rename=20getArtistResearch=20?= =?UTF-8?q?=E2=86=92=20handleArtistResearch=20(no=20functional=20change)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure rename: file, symbol, test file, and all 15 handler call sites. Keeps the new composable-helper implementation — only the name now matches the legacy naming. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...search.test.ts => handleArtistResearch.test.ts} | 14 +++++++------- lib/research/getResearchAlbumsHandler.ts | 4 ++-- lib/research/getResearchAudienceHandler.ts | 4 ++-- lib/research/getResearchCareerHandler.ts | 4 ++-- lib/research/getResearchCitiesHandler.ts | 4 ++-- lib/research/getResearchInsightsHandler.ts | 4 ++-- lib/research/getResearchInstagramPostsHandler.ts | 4 ++-- lib/research/getResearchMetricsHandler.ts | 4 ++-- lib/research/getResearchMilestonesHandler.ts | 4 ++-- lib/research/getResearchPlaylistsHandler.ts | 4 ++-- lib/research/getResearchProfileHandler.ts | 4 ++-- lib/research/getResearchRankHandler.ts | 4 ++-- lib/research/getResearchSimilarHandler.ts | 4 ++-- lib/research/getResearchTracksHandler.ts | 4 ++-- lib/research/getResearchUrlsHandler.ts | 4 ++-- lib/research/getResearchVenuesHandler.ts | 4 ++-- ...etArtistResearch.ts => handleArtistResearch.ts} | 10 +++++----- 17 files changed, 42 insertions(+), 42 deletions(-) rename lib/research/__tests__/{getArtistResearch.test.ts => handleArtistResearch.test.ts} (90%) rename lib/research/{getArtistResearch.ts => handleArtistResearch.ts} (83%) diff --git a/lib/research/__tests__/getArtistResearch.test.ts b/lib/research/__tests__/handleArtistResearch.test.ts similarity index 90% rename from lib/research/__tests__/getArtistResearch.test.ts rename to lib/research/__tests__/handleArtistResearch.test.ts index 8803608df..79e656c7b 100644 --- a/lib/research/__tests__/getArtistResearch.test.ts +++ b/lib/research/__tests__/handleArtistResearch.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getArtistResearch } from "../getArtistResearch"; +import { handleArtistResearch } from "../handleArtistResearch"; import { resolveArtist } from "@/lib/research/resolveArtist"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; @@ -15,7 +15,7 @@ vi.mock("@/lib/credits/deductCredits", () => ({ deductCredits: vi.fn(), })); -describe("getArtistResearch", () => { +describe("handleArtistResearch", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -23,7 +23,7 @@ describe("getArtistResearch", () => { it("returns { error, status: 404 } when artist cannot be resolved", async () => { vi.mocked(resolveArtist).mockResolvedValue({ error: "Artist not found" } as never); - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: "Unknown", accountId: "acc_1", path: id => `/artist/${id}/albums`, @@ -42,7 +42,7 @@ describe("getArtistResearch", () => { } as never); vi.mocked(deductCredits).mockResolvedValue(undefined as never); - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: "Drake", accountId: "acc_1", path: id => `/artist/${id}/albums`, @@ -57,7 +57,7 @@ describe("getArtistResearch", () => { vi.mocked(resolveArtist).mockResolvedValue({ id: 7 } as never); vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: {} } as never); - await getArtistResearch({ + await handleArtistResearch({ artist: "X", accountId: "acc_1", path: id => `/artist/${id}/playlists`, @@ -74,7 +74,7 @@ describe("getArtistResearch", () => { vi.mocked(resolveArtist).mockResolvedValue({ id: 1 } as never); vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 502, data: null } as never); - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: "X", accountId: "acc_1", path: id => `/artist/${id}/x`, @@ -89,7 +89,7 @@ describe("getArtistResearch", () => { vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(deductCredits).mockRejectedValue(new Error("DB down")); - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: "X", accountId: "acc_1", path: () => "/x", diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 68bee0281..71aa5681c 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -17,7 +17,7 @@ export async function getResearchAlbumsHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/albums`, diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts index 0620062ad..28a5b8091 100644 --- a/lib/research/getResearchAudienceHandler.ts +++ b/lib/research/getResearchAudienceHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -21,7 +21,7 @@ export async function getResearchAudienceHandler(request: NextRequest) { const { searchParams } = new URL(request.url); const platform = searchParams.get("platform") || "instagram"; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/${platform}-audience-stats`, diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts index 20d6c00ba..811ad9e7d 100644 --- a/lib/research/getResearchCareerHandler.ts +++ b/lib/research/getResearchCareerHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -17,7 +17,7 @@ export async function getResearchCareerHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/career`, diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts index 959b98401..d9fca6ffc 100644 --- a/lib/research/getResearchCitiesHandler.ts +++ b/lib/research/getResearchCitiesHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -17,7 +17,7 @@ export async function getResearchCitiesHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/where-people-listen`, diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts index a6547d85c..3c6ca53ca 100644 --- a/lib/research/getResearchInsightsHandler.ts +++ b/lib/research/getResearchInsightsHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -18,7 +18,7 @@ export async function getResearchInsightsHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/noteworthy-insights`, diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index b9ab5868e..aea597573 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -18,7 +18,7 @@ export async function getResearchInstagramPostsHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`, diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts index 70deae767..31fbf8a14 100644 --- a/lib/research/getResearchMetricsHandler.ts +++ b/lib/research/getResearchMetricsHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -46,7 +46,7 @@ export async function getResearchMetricsHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/stat/${source}`, diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts index cdb9b7fed..845be7c92 100644 --- a/lib/research/getResearchMilestonesHandler.ts +++ b/lib/research/getResearchMilestonesHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -17,7 +17,7 @@ export async function getResearchMilestonesHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/milestones`, diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index 90a2acbbd..beb122c56 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -52,7 +52,7 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { query.popularIndie = "true"; } - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/${platform}/${status}/playlists`, diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts index df9347d99..fc1155611 100644 --- a/lib/research/getResearchProfileHandler.ts +++ b/lib/research/getResearchProfileHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -17,7 +17,7 @@ export async function getResearchProfileHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}`, diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts index 45b219eb3..9affb6f87 100644 --- a/lib/research/getResearchRankHandler.ts +++ b/lib/research/getResearchRankHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -16,7 +16,7 @@ export async function getResearchRankHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/artist-rank`, diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts index a96666d5f..70e3e5281 100644 --- a/lib/research/getResearchSimilarHandler.ts +++ b/lib/research/getResearchSimilarHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; @@ -30,7 +30,7 @@ export async function getResearchSimilarHandler(request: NextRequest) { const limit = searchParams.get("limit"); if (limit) query.limit = limit; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/similar-artists/by-configurations`, diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts index 778dbe4ec..647e0d6c2 100644 --- a/lib/research/getResearchTracksHandler.ts +++ b/lib/research/getResearchTracksHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -17,7 +17,7 @@ export async function getResearchTracksHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/tracks`, diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts index 8d5b3fb74..90b3ba7dd 100644 --- a/lib/research/getResearchUrlsHandler.ts +++ b/lib/research/getResearchUrlsHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -18,7 +18,7 @@ export async function getResearchUrlsHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/urls`, diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts index a44192752..c142dfa23 100644 --- a/lib/research/getResearchVenuesHandler.ts +++ b/lib/research/getResearchVenuesHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; -import { getArtistResearch } from "@/lib/research/getArtistResearch"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; /** @@ -16,7 +16,7 @@ export async function getResearchVenuesHandler(request: NextRequest) { const gate = await requireArtist(request); if (gate instanceof NextResponse) return gate; - const result = await getArtistResearch({ + const result = await handleArtistResearch({ artist: gate.artist, accountId: gate.accountId, path: cmId => `/artist/${cmId}/venues`, diff --git a/lib/research/getArtistResearch.ts b/lib/research/handleArtistResearch.ts similarity index 83% rename from lib/research/getArtistResearch.ts rename to lib/research/handleArtistResearch.ts index 7f4a3c7c2..7aa0e29e4 100644 --- a/lib/research/getArtistResearch.ts +++ b/lib/research/handleArtistResearch.ts @@ -2,7 +2,7 @@ import { resolveArtist } from "@/lib/research/resolveArtist"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; -export type GetArtistResearchParams = { +export type HandleArtistResearchParams = { artist: string; accountId: string; path: (cmId: number) => string; @@ -11,7 +11,7 @@ export type GetArtistResearchParams = { credits?: number; }; -export type GetArtistResearchResult = { data: unknown } | { error: string; status: number }; +export type HandleArtistResearchResult = { data: unknown } | { error: string; status: number }; /** * Resolves an artist to a Chartmetric ID, proxies to the built upstream path, @@ -21,9 +21,9 @@ export type GetArtistResearchResult = { data: unknown } | { error: string; statu * * @returns `{ data }` on success, `{ error, status }` on failure. */ -export async function getArtistResearch( - params: GetArtistResearchParams, -): Promise { +export async function handleArtistResearch( + params: HandleArtistResearchParams, +): Promise { const { artist, accountId, path, query, credits = 5 } = params; const resolved = await resolveArtist(artist); From aae9670e84fe1b1d18d16173163bdfab2ed0a513 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 15:32:20 -0500 Subject: [PATCH 12/28] refactor: consolidate JSON response helpers (DRY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit errorResponse was duplicated across lib/networking/errorResponse.ts (existing) and a new lib/networking/jsonResponse.ts helper added with this PR's research refactor. Merge them: - Update errorResponse to return { status: "error", error } (was just { error }) so success/error envelopes are symmetric. - Move jsonSuccess into a dedicated successResponse.ts file (one exported function per file per repo convention). - Delete lib/networking/jsonResponse.ts. - Point the 15 research handlers + requireArtist at the unified helpers. The one pre-existing caller of errorResponse (validateAgentVerifyBody) now gets status: "error" in its body. Its tests only assert status codes, not body shape — no test changes needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/errorResponse.test.ts | 8 ++---- ...sponse.test.ts => successResponse.test.ts} | 16 +++--------- lib/networking/errorResponse.ts | 9 +++---- lib/networking/jsonResponse.ts | 26 ------------------- lib/networking/successResponse.ts | 15 +++++++++++ lib/research/getResearchAlbumsHandler.ts | 7 ++--- lib/research/getResearchAudienceHandler.ts | 7 ++--- lib/research/getResearchCareerHandler.ts | 7 ++--- lib/research/getResearchCitiesHandler.ts | 7 ++--- lib/research/getResearchInsightsHandler.ts | 7 ++--- .../getResearchInstagramPostsHandler.ts | 7 ++--- lib/research/getResearchMetricsHandler.ts | 11 ++++---- lib/research/getResearchMilestonesHandler.ts | 7 ++--- lib/research/getResearchPlaylistsHandler.ts | 9 ++++--- lib/research/getResearchProfileHandler.ts | 7 ++--- lib/research/getResearchRankHandler.ts | 7 ++--- lib/research/getResearchSimilarHandler.ts | 7 ++--- lib/research/getResearchTracksHandler.ts | 7 ++--- lib/research/getResearchUrlsHandler.ts | 7 ++--- lib/research/getResearchVenuesHandler.ts | 7 ++--- lib/research/requireArtist.ts | 4 +-- 21 files changed, 89 insertions(+), 100 deletions(-) rename lib/networking/__tests__/{jsonResponse.test.ts => successResponse.test.ts} (51%) delete mode 100644 lib/networking/jsonResponse.ts create mode 100644 lib/networking/successResponse.ts diff --git a/lib/networking/__tests__/errorResponse.test.ts b/lib/networking/__tests__/errorResponse.test.ts index bce015381..12857f1a6 100644 --- a/lib/networking/__tests__/errorResponse.test.ts +++ b/lib/networking/__tests__/errorResponse.test.ts @@ -6,16 +6,12 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); describe("errorResponse", () => { - it("returns a NextResponse with the given message and status", async () => { + it("returns { status: 'error', error } at the given HTTP status with CORS headers", async () => { const result = errorResponse("Something went wrong", 400); const body = await result.json(); expect(result.status).toBe(400); - expect(body.error).toBe("Something went wrong"); - }); - - it("includes CORS headers on the response", () => { - const result = errorResponse("nope", 429); + expect(body).toEqual({ status: "error", error: "Something went wrong" }); expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); diff --git a/lib/networking/__tests__/jsonResponse.test.ts b/lib/networking/__tests__/successResponse.test.ts similarity index 51% rename from lib/networking/__tests__/jsonResponse.test.ts rename to lib/networking/__tests__/successResponse.test.ts index b3178da18..20b931c77 100644 --- a/lib/networking/__tests__/jsonResponse.test.ts +++ b/lib/networking/__tests__/successResponse.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, vi } from "vitest"; import { NextResponse } from "next/server"; -import { jsonSuccess, jsonError } from "../jsonResponse"; +import { successResponse } from "../successResponse"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), })); -describe("jsonSuccess", () => { +describe("successResponse", () => { it("returns 200 with { status: 'success', ...body } and CORS headers", async () => { - const res = jsonSuccess({ albums: ["a"] }); + const res = successResponse({ albums: ["a"] }); expect(res).toBeInstanceOf(NextResponse); expect(res.status).toBe(200); expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); @@ -17,13 +17,3 @@ describe("jsonSuccess", () => { expect(body).toEqual({ status: "success", albums: ["a"] }); }); }); - -describe("jsonError", () => { - it("returns the given status with { status: 'error', error } and CORS headers", async () => { - const res = jsonError(404, "Not found"); - expect(res.status).toBe(404); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - const body = await res.json(); - expect(body).toEqual({ status: "error", error: "Not found" }); - }); -}); diff --git a/lib/networking/errorResponse.ts b/lib/networking/errorResponse.ts index 41beb3b7a..c2ecbb131 100644 --- a/lib/networking/errorResponse.ts +++ b/lib/networking/errorResponse.ts @@ -2,14 +2,13 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; /** - * Builds a JSON error response with CORS headers. Use this instead of - * inlining `NextResponse.json({ error }, { status, headers: getCorsHeaders() })` - * at every API error return site. + * Builds a JSON error response in the standard `{ status: "error", error }` + * envelope with CORS headers. Use in place of inlining + * `NextResponse.json({ status: "error", error }, { status, headers: getCorsHeaders() })`. * * @param error - Human-readable error message * @param status - HTTP status code (e.g. 400, 403, 429, 500) - * @returns NextResponse carrying `{ error }` at the given status */ export function errorResponse(error: string, status: number): NextResponse { - return NextResponse.json({ error }, { status, headers: getCorsHeaders() }); + return NextResponse.json({ status: "error", error }, { status, headers: getCorsHeaders() }); } diff --git a/lib/networking/jsonResponse.ts b/lib/networking/jsonResponse.ts deleted file mode 100644 index 76de1c9ca..000000000 --- a/lib/networking/jsonResponse.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; - -/** - * Wraps a success payload in the standard `{ status: "success", ...body }` - * envelope with CORS headers applied. - * - * @param body - Fields to spread at the root of the response. - */ -export function jsonSuccess(body: Record): NextResponse { - return NextResponse.json( - { status: "success", ...body }, - { status: 200, headers: getCorsHeaders() }, - ); -} - -/** - * Returns an error response with the standard `{ status: "error", error }` - * envelope and CORS headers. - * - * @param status - HTTP status code - * @param error - Human-readable error message - */ -export function jsonError(status: number, error: string): NextResponse { - return NextResponse.json({ status: "error", error }, { status, headers: getCorsHeaders() }); -} diff --git a/lib/networking/successResponse.ts b/lib/networking/successResponse.ts new file mode 100644 index 000000000..6a6e0589c --- /dev/null +++ b/lib/networking/successResponse.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Wraps a success payload in the standard `{ status: "success", ...body }` + * envelope with CORS headers. Pairs with {@link errorResponse}. + * + * @param body - Fields to spread at the root of the response. + */ +export function successResponse(body: Record): NextResponse { + return NextResponse.json( + { status: "success", ...body }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 71aa5681c..57a70f4f5 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/albums @@ -23,6 +24,6 @@ export async function getResearchAlbumsHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/albums`, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ albums: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ albums: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts index 28a5b8091..b9ae54bc0 100644 --- a/lib/research/getResearchAudienceHandler.ts +++ b/lib/research/getResearchAudienceHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/audience @@ -27,11 +28,11 @@ export async function getResearchAudienceHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/${platform}-audience-stats`, }); - if ("error" in result) return jsonError(result.status, result.error); + if ("error" in result) return errorResponse(result.error, result.status); const data = result.data; const body = typeof data === "object" && data !== null && !Array.isArray(data) ? (data as Record) : { data }; - return jsonSuccess(body); + return successResponse(body); } diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts index 811ad9e7d..0fcd0409e 100644 --- a/lib/research/getResearchCareerHandler.ts +++ b/lib/research/getResearchCareerHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/career @@ -23,6 +24,6 @@ export async function getResearchCareerHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/career`, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ career: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ career: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts index d9fca6ffc..f1fa34d9b 100644 --- a/lib/research/getResearchCitiesHandler.ts +++ b/lib/research/getResearchCitiesHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/cities @@ -23,7 +24,7 @@ export async function getResearchCitiesHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/where-people-listen`, }); - if ("error" in result) return jsonError(result.status, result.error); + if ("error" in result) return errorResponse(result.error, result.status); const raw = (result.data as { cities?: Record> }) @@ -36,5 +37,5 @@ export async function getResearchCitiesHandler(request: NextRequest) { })) .sort((a, b) => b.listeners - a.listeners); - return jsonSuccess({ cities }); + return successResponse({ cities }); } diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts index 3c6ca53ca..b9f29d1e5 100644 --- a/lib/research/getResearchInsightsHandler.ts +++ b/lib/research/getResearchInsightsHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/insights @@ -24,6 +25,6 @@ export async function getResearchInsightsHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/noteworthy-insights`, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ insights: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ insights: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index aea597573..57ad10cfc 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/instagram-posts @@ -24,11 +25,11 @@ export async function getResearchInstagramPostsHandler(request: NextRequest) { path: cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`, }); - if ("error" in result) return jsonError(result.status, result.error); + if ("error" in result) return errorResponse(result.error, result.status); const data = result.data; const body = typeof data === "object" && data !== null && !Array.isArray(data) ? (data as Record) : { data }; - return jsonSuccess(body); + return successResponse(body); } diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts index 31fbf8a14..d185a7507 100644 --- a/lib/research/getResearchMetricsHandler.ts +++ b/lib/research/getResearchMetricsHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/metrics @@ -19,7 +20,7 @@ export async function getResearchMetricsHandler(request: NextRequest) { const source = searchParams.get("source"); if (!source) { - return jsonError(400, "source parameter is required"); + return errorResponse("source parameter is required", 400); } const VALID_SOURCES = [ @@ -40,7 +41,7 @@ export async function getResearchMetricsHandler(request: NextRequest) { ]; if (!VALID_SOURCES.includes(source)) { - return jsonError(400, `Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}`); + return errorResponse(`Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}`, 400); } const gate = await requireArtist(request); @@ -52,11 +53,11 @@ export async function getResearchMetricsHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/stat/${source}`, }); - if ("error" in result) return jsonError(result.status, result.error); + if ("error" in result) return errorResponse(result.error, result.status); const data = result.data; const body = typeof data === "object" && data !== null && !Array.isArray(data) ? (data as Record) : { data }; - return jsonSuccess(body); + return successResponse(body); } diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts index 845be7c92..995819575 100644 --- a/lib/research/getResearchMilestonesHandler.ts +++ b/lib/research/getResearchMilestonesHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/milestones @@ -23,8 +24,8 @@ export async function getResearchMilestonesHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/milestones`, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ milestones: (result.data as Record)?.insights || [], }); } diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index beb122c56..f513b35b6 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * Playlists handler — returns playlists featuring an artist. Supports `?platform=`, `?status=`, `?limit=`, `?sort=`, `?since=`, and playlist-type filters. @@ -17,7 +18,7 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; if (!VALID_PLATFORMS.includes(platform)) { - return jsonError(400, `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`); + return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); } const gate = await requireArtist(request); @@ -59,6 +60,6 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { query, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ placements: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ placements: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts index fc1155611..0c0baf542 100644 --- a/lib/research/getResearchProfileHandler.ts +++ b/lib/research/getResearchProfileHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/profile @@ -23,11 +24,11 @@ export async function getResearchProfileHandler(request: NextRequest) { path: cmId => `/artist/${cmId}`, }); - if ("error" in result) return jsonError(result.status, result.error); + if ("error" in result) return errorResponse(result.error, result.status); const data = result.data; const body = typeof data === "object" && data !== null && !Array.isArray(data) ? (data as Record) : { data }; - return jsonSuccess(body); + return successResponse(body); } diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts index 9affb6f87..1cde3c1db 100644 --- a/lib/research/getResearchRankHandler.ts +++ b/lib/research/getResearchRankHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/rank @@ -22,8 +23,8 @@ export async function getResearchRankHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/artist-rank`, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ rank: (result.data as Record)?.artist_rank || null, }); } diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts index 70e3e5281..c685ecbe3 100644 --- a/lib/research/getResearchSimilarHandler.ts +++ b/lib/research/getResearchSimilarHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; @@ -37,9 +38,9 @@ export async function getResearchSimilarHandler(request: NextRequest) { query, }); - if ("error" in result) return jsonError(result.status, result.error); + if ("error" in result) return errorResponse(result.error, result.status); const data = result.data; - return jsonSuccess({ + return successResponse({ artists: Array.isArray(data) ? data : (data as Record)?.data || [], total: (data as Record)?.total, }); diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts index 647e0d6c2..1a4256b9d 100644 --- a/lib/research/getResearchTracksHandler.ts +++ b/lib/research/getResearchTracksHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/tracks @@ -23,6 +24,6 @@ export async function getResearchTracksHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/tracks`, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ tracks: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ tracks: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts index 90b3ba7dd..ba04509cd 100644 --- a/lib/research/getResearchUrlsHandler.ts +++ b/lib/research/getResearchUrlsHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/urls @@ -24,9 +25,9 @@ export async function getResearchUrlsHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/urls`, }); - if ("error" in result) return jsonError(result.status, result.error); + if ("error" in result) return errorResponse(result.error, result.status); const data = result.data; - return jsonSuccess({ + return successResponse({ urls: Array.isArray(data) ? data : Object.entries(data as Record).map(([domain, url]) => ({ domain, url })), diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts index c142dfa23..fd6762776 100644 --- a/lib/research/getResearchVenuesHandler.ts +++ b/lib/research/getResearchVenuesHandler.ts @@ -2,7 +2,8 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireArtist } from "@/lib/research/requireArtist"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { jsonSuccess, jsonError } from "@/lib/networking/jsonResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/venues @@ -22,6 +23,6 @@ export async function getResearchVenuesHandler(request: NextRequest) { path: cmId => `/artist/${cmId}/venues`, }); - if ("error" in result) return jsonError(result.status, result.error); - return jsonSuccess({ venues: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ venues: Array.isArray(result.data) ? result.data : [] }); } diff --git a/lib/research/requireArtist.ts b/lib/research/requireArtist.ts index 2f200d397..722facf85 100644 --- a/lib/research/requireArtist.ts +++ b/lib/research/requireArtist.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { jsonError } from "@/lib/networking/jsonResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; /** * Auth + `artist` query param gate for artist-scoped research endpoints. @@ -16,7 +16,7 @@ export async function requireArtist( if (authResult instanceof NextResponse) return authResult; const artist = new URL(request.url).searchParams.get("artist"); - if (!artist) return jsonError(400, "artist parameter is required"); + if (!artist) return errorResponse("artist parameter is required", 400); return { accountId: authResult.accountId, artist }; } From 66fa96fc886ffb89b798f7ffd03ffc47aaae0a3e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 15:52:15 -0500 Subject: [PATCH 13/28] refactor: align research handler naming + shape with codebase conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateArtistRequest (was requireArtist) — matches validate*Request family - Composed validators for metrics/playlists/similar endpoints whose inline validation was non-trivial - Add try/catch + explicit Promise return type to all 15 artist-scoped research handlers - Rename `gate` local to `validated` to match the rest of the codebase No behavior change. Co-Authored-By: Claude Opus 4.6 (1M context) --- ....test.ts => validateArtistRequest.test.ts} | 10 +- .../validateGetResearchMetricsRequest.test.ts | 59 ++++++++++++ ...alidateGetResearchPlaylistsRequest.test.ts | 52 +++++++++++ .../validateGetResearchSimilarRequest.test.ts | 60 ++++++++++++ lib/research/getResearchAlbumsHandler.ts | 27 +++--- lib/research/getResearchAudienceHandler.ts | 41 +++++---- lib/research/getResearchCareerHandler.ts | 27 +++--- lib/research/getResearchCitiesHandler.ts | 47 +++++----- lib/research/getResearchInsightsHandler.ts | 27 +++--- .../getResearchInstagramPostsHandler.ts | 39 ++++---- lib/research/getResearchMetricsHandler.ts | 63 ++++--------- lib/research/getResearchMilestonesHandler.ts | 31 ++++--- lib/research/getResearchPlaylistsHandler.ts | 92 +++++++++---------- lib/research/getResearchProfileHandler.ts | 37 ++++---- lib/research/getResearchRankHandler.ts | 31 ++++--- lib/research/getResearchSimilarHandler.ts | 54 +++++------ lib/research/getResearchTracksHandler.ts | 27 +++--- lib/research/getResearchUrlsHandler.ts | 37 ++++---- lib/research/getResearchVenuesHandler.ts | 27 +++--- ...uireArtist.ts => validateArtistRequest.ts} | 2 +- .../validateGetResearchMetricsRequest.ts | 46 ++++++++++ .../validateGetResearchPlaylistsRequest.ts | 42 +++++++++ .../validateGetResearchSimilarRequest.ts | 52 +++++++++++ 23 files changed, 640 insertions(+), 290 deletions(-) rename lib/research/__tests__/{requireArtist.test.ts => validateArtistRequest.test.ts} (77%) create mode 100644 lib/research/__tests__/validateGetResearchMetricsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchPlaylistsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchSimilarRequest.test.ts rename lib/research/{requireArtist.ts => validateArtistRequest.ts} (95%) create mode 100644 lib/research/validateGetResearchMetricsRequest.ts create mode 100644 lib/research/validateGetResearchPlaylistsRequest.ts create mode 100644 lib/research/validateGetResearchSimilarRequest.ts diff --git a/lib/research/__tests__/requireArtist.test.ts b/lib/research/__tests__/validateArtistRequest.test.ts similarity index 77% rename from lib/research/__tests__/requireArtist.test.ts rename to lib/research/__tests__/validateArtistRequest.test.ts index d30a07f50..f4f0661d3 100644 --- a/lib/research/__tests__/requireArtist.test.ts +++ b/lib/research/__tests__/validateArtistRequest.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { requireArtist } from "../requireArtist"; +import { validateArtistRequest } from "../validateArtistRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -describe("requireArtist", () => { +describe("validateArtistRequest", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -17,7 +17,7 @@ describe("requireArtist", () => { const unauthorized = NextResponse.json({ error: "x" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(unauthorized); - const result = await requireArtist(new NextRequest("http://x/?artist=drake")); + const result = await validateArtistRequest(new NextRequest("http://x/?artist=drake")); expect(result).toBe(unauthorized); }); @@ -25,7 +25,7 @@ describe("requireArtist", () => { it("returns a 400 response when artist is missing", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); - const result = await requireArtist(new NextRequest("http://x/")); + const result = await validateArtistRequest(new NextRequest("http://x/")); expect(result).toBeInstanceOf(NextResponse); if (result instanceof NextResponse) { @@ -38,7 +38,7 @@ describe("requireArtist", () => { it("returns accountId and artist on success", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); - const result = await requireArtist(new NextRequest("http://x/?artist=Drake")); + const result = await validateArtistRequest(new NextRequest("http://x/?artist=Drake")); expect(result).toEqual({ accountId: "acc_1", artist: "Drake" }); }); diff --git a/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts new file mode 100644 index 000000000..85770abdf --- /dev/null +++ b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchMetricsRequest } from "../validateGetResearchMetricsRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("validateGetResearchMetricsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + }); + + it("returns 400 when source is missing", async () => { + const result = await validateGetResearchMetricsRequest( + new NextRequest("http://x/?artist=Drake"), + ); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("source parameter is required"); + } + }); + + it("returns 400 when source is not in the allowed list", async () => { + const result = await validateGetResearchMetricsRequest( + new NextRequest("http://x/?artist=Drake&source=myspace"), + ); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toContain("Invalid source"); + } + }); + + it("delegates artist validation to validateArtistRequest (400 when artist missing)", async () => { + const result = await validateGetResearchMetricsRequest( + new NextRequest("http://x/?source=spotify"), + ); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("artist parameter is required"); + } + }); + + it("returns accountId, artist, and source on success", async () => { + const result = await validateGetResearchMetricsRequest( + new NextRequest("http://x/?artist=Drake&source=spotify"), + ); + expect(result).toEqual({ accountId: "acc_1", artist: "Drake", source: "spotify" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchPlaylistsRequest.test.ts b/lib/research/__tests__/validateGetResearchPlaylistsRequest.test.ts new file mode 100644 index 000000000..a9c7c5d51 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchPlaylistsRequest.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchPlaylistsRequest } from "../validateGetResearchPlaylistsRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("validateGetResearchPlaylistsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + }); + + it("returns 400 when platform is not allowed", async () => { + const result = await validateGetResearchPlaylistsRequest( + new NextRequest("http://x/?artist=Drake&platform=myspace"), + ); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toContain("Invalid platform"); + } + }); + + it("defaults platform to 'spotify' and status to 'current' when omitted", async () => { + const result = await validateGetResearchPlaylistsRequest( + new NextRequest("http://x/?artist=Drake"), + ); + expect(result).toEqual({ + accountId: "acc_1", + artist: "Drake", + platform: "spotify", + status: "current", + }); + }); + + it("accepts explicit platform and status values", async () => { + const result = await validateGetResearchPlaylistsRequest( + new NextRequest("http://x/?artist=Drake&platform=applemusic&status=past"), + ); + expect(result).toEqual({ + accountId: "acc_1", + artist: "Drake", + platform: "applemusic", + status: "past", + }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchSimilarRequest.test.ts b/lib/research/__tests__/validateGetResearchSimilarRequest.test.ts new file mode 100644 index 000000000..86778f5e2 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchSimilarRequest.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchSimilarRequest } from "../validateGetResearchSimilarRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("validateGetResearchSimilarRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + }); + + it("defaults each axis to 'medium' when omitted", async () => { + const result = await validateGetResearchSimilarRequest( + new NextRequest("http://x/?artist=Drake"), + ); + expect(result).toEqual({ + accountId: "acc_1", + artist: "Drake", + audience: "medium", + genre: "medium", + mood: "medium", + musicality: "medium", + limit: undefined, + }); + }); + + it("accepts explicit axis values", async () => { + const result = await validateGetResearchSimilarRequest( + new NextRequest( + "http://x/?artist=Drake&audience=high&genre=low&mood=medium&musicality=high&limit=10", + ), + ); + expect(result).toEqual({ + accountId: "acc_1", + artist: "Drake", + audience: "high", + genre: "low", + mood: "medium", + musicality: "high", + limit: "10", + }); + }); + + it("returns 400 when an axis is not high|medium|low", async () => { + const result = await validateGetResearchSimilarRequest( + new NextRequest("http://x/?artist=Drake&audience=extreme"), + ); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toContain("audience"); + } + }); +}); diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 57a70f4f5..295abdf6d 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -14,16 +14,21 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchAlbumsHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchAlbumsHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/albums`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/albums`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ albums: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ albums: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchAlbumsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts index b9ae54bc0..37754607e 100644 --- a/lib/research/getResearchAudienceHandler.ts +++ b/lib/research/getResearchAudienceHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -15,24 +15,29 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchAudienceHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchAudienceHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform") || "instagram"; + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform") || "instagram"; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/${platform}-audience-stats`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/${platform}-audience-stats`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); + if ("error" in result) return errorResponse(result.error, result.status); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchAudienceHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts index 0fcd0409e..543c1c8c5 100644 --- a/lib/research/getResearchCareerHandler.ts +++ b/lib/research/getResearchCareerHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -14,16 +14,21 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchCareerHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchCareerHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/career`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/career`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ career: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ career: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchCareerHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts index f1fa34d9b..f8b52fb04 100644 --- a/lib/research/getResearchCitiesHandler.ts +++ b/lib/research/getResearchCitiesHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -14,28 +14,33 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchCitiesHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchCitiesHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/where-people-listen`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/where-people-listen`, + }); - if ("error" in result) return errorResponse(result.error, result.status); + if ("error" in result) return errorResponse(result.error, result.status); - const raw = - (result.data as { cities?: Record> }) - ?.cities || {}; - const cities = Object.entries(raw) - .map(([name, points]) => ({ - name, - country: points[points.length - 1]?.code2 || "", - listeners: points[points.length - 1]?.listeners || 0, - })) - .sort((a, b) => b.listeners - a.listeners); + const raw = + (result.data as { cities?: Record> }) + ?.cities || {}; + const cities = Object.entries(raw) + .map(([name, points]) => ({ + name, + country: points[points.length - 1]?.code2 || "", + listeners: points[points.length - 1]?.listeners || 0, + })) + .sort((a, b) => b.listeners - a.listeners); - return successResponse({ cities }); + return successResponse({ cities }); + } catch (error) { + console.error("[ERROR] getResearchCitiesHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts index b9f29d1e5..8b9b48da4 100644 --- a/lib/research/getResearchInsightsHandler.ts +++ b/lib/research/getResearchInsightsHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -15,16 +15,21 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchInsightsHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchInsightsHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/noteworthy-insights`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/noteworthy-insights`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ insights: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ insights: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchInsightsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index 57ad10cfc..721817fca 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -15,21 +15,28 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchInstagramPostsHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchInstagramPostsHandler( + request: NextRequest, +): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); + if ("error" in result) return errorResponse(result.error, result.status); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchInstagramPostsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts index d185a7507..d469d60c5 100644 --- a/lib/research/getResearchMetricsHandler.ts +++ b/lib/research/getResearchMetricsHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateGetResearchMetricsRequest } from "@/lib/research/validateGetResearchMetricsRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -15,49 +15,26 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchMetricsHandler(request: NextRequest) { - const { searchParams } = new URL(request.url); - const source = searchParams.get("source"); +export async function getResearchMetricsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchMetricsRequest(request); + if (validated instanceof NextResponse) return validated; - if (!source) { - return errorResponse("source parameter is required", 400); - } - - const VALID_SOURCES = [ - "spotify", - "instagram", - "tiktok", - "twitter", - "facebook", - "youtube_channel", - "youtube_artist", - "soundcloud", - "deezer", - "twitch", - "line", - "melon", - "wikipedia", - "bandsintown", - ]; + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/stat/${validated.source}`, + }); - if (!VALID_SOURCES.includes(source)) { - return errorResponse(`Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}`, 400); + if ("error" in result) return errorResponse(result.error, result.status); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchMetricsHandler:", error); + return errorResponse("Internal error", 500); } - - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; - - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/stat/${source}`, - }); - - if ("error" in result) return errorResponse(result.error, result.status); - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); } diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts index 995819575..41fecc0eb 100644 --- a/lib/research/getResearchMilestonesHandler.ts +++ b/lib/research/getResearchMilestonesHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -14,18 +14,23 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchMilestonesHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchMilestonesHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/milestones`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/milestones`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ - milestones: (result.data as Record)?.insights || [], - }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ + milestones: (result.data as Record)?.insights || [], + }); + } catch (error) { + console.error("[ERROR] getResearchMilestonesHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index f513b35b6..5d1bebfb3 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateGetResearchPlaylistsRequest } from "@/lib/research/validateGetResearchPlaylistsRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -11,55 +11,53 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - must include `artist` query param * @returns JSON playlist placements or error */ -export async function getResearchPlaylistsHandler(request: NextRequest) { - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform") || "spotify"; - const status = searchParams.get("status") || "current"; +export async function getResearchPlaylistsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchPlaylistsRequest(request); + if (validated instanceof NextResponse) return validated; - const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; - if (!VALID_PLATFORMS.includes(platform)) { - return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); - } - - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; + const { searchParams } = new URL(request.url); - const query: Record = {}; - const limit = searchParams.get("limit"); - if (limit) query.limit = limit; - const sort = searchParams.get("sort"); - if (sort) query.sortColumn = sort; - const since = searchParams.get("since"); - if (since) query.since = since; + const query: Record = {}; + const limit = searchParams.get("limit"); + if (limit) query.limit = limit; + const sort = searchParams.get("sort"); + if (sort) query.sortColumn = sort; + const since = searchParams.get("since"); + if (since) query.since = since; - const hasFilters = - searchParams.get("editorial") || - searchParams.get("indie") || - searchParams.get("majorCurator") || - searchParams.get("popularIndie") || - searchParams.get("personalized") || - searchParams.get("chart"); - if (hasFilters) { - if (searchParams.get("editorial")) query.editorial = searchParams.get("editorial")!; - if (searchParams.get("indie")) query.indie = searchParams.get("indie")!; - if (searchParams.get("majorCurator")) query.majorCurator = searchParams.get("majorCurator")!; - if (searchParams.get("popularIndie")) query.popularIndie = searchParams.get("popularIndie")!; - if (searchParams.get("personalized")) query.personalized = searchParams.get("personalized")!; - if (searchParams.get("chart")) query.chart = searchParams.get("chart")!; - } else { - query.editorial = "true"; - query.indie = "true"; - query.majorCurator = "true"; - query.popularIndie = "true"; - } + const hasFilters = + searchParams.get("editorial") || + searchParams.get("indie") || + searchParams.get("majorCurator") || + searchParams.get("popularIndie") || + searchParams.get("personalized") || + searchParams.get("chart"); + if (hasFilters) { + if (searchParams.get("editorial")) query.editorial = searchParams.get("editorial")!; + if (searchParams.get("indie")) query.indie = searchParams.get("indie")!; + if (searchParams.get("majorCurator")) query.majorCurator = searchParams.get("majorCurator")!; + if (searchParams.get("popularIndie")) query.popularIndie = searchParams.get("popularIndie")!; + if (searchParams.get("personalized")) query.personalized = searchParams.get("personalized")!; + if (searchParams.get("chart")) query.chart = searchParams.get("chart")!; + } else { + query.editorial = "true"; + query.indie = "true"; + query.majorCurator = "true"; + query.popularIndie = "true"; + } - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/${platform}/${status}/playlists`, - query, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/${validated.platform}/${validated.status}/playlists`, + query, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ placements: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ placements: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchPlaylistsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts index 0c0baf542..c6a49e4ed 100644 --- a/lib/research/getResearchProfileHandler.ts +++ b/lib/research/getResearchProfileHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -14,21 +14,26 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchProfileHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchProfileHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); + if ("error" in result) return errorResponse(result.error, result.status); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchProfileHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts index 1cde3c1db..062641a7e 100644 --- a/lib/research/getResearchRankHandler.ts +++ b/lib/research/getResearchRankHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -13,18 +13,23 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchRankHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchRankHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/artist-rank`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/artist-rank`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ - rank: (result.data as Record)?.artist_rank || null, - }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ + rank: (result.data as Record)?.artist_rank || null, + }); + } catch (error) { + console.error("[ERROR] getResearchRankHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts index c685ecbe3..2707760f0 100644 --- a/lib/research/getResearchSimilarHandler.ts +++ b/lib/research/getResearchSimilarHandler.ts @@ -1,12 +1,10 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateGetResearchSimilarRequest } from "@/lib/research/validateGetResearchSimilarRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; -const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; - /** * GET /api/research/similar * @@ -18,30 +16,34 @@ const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchSimilarHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchSimilarHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchSimilarRequest(request); + if (validated instanceof NextResponse) return validated; - const { searchParams } = new URL(request.url); - const query: Record = {}; - for (const key of CONFIG_PARAMS) { - const val = searchParams.get(key); - query[key] = val || "medium"; - } - const limit = searchParams.get("limit"); - if (limit) query.limit = limit; + const query: Record = { + audience: validated.audience, + genre: validated.genre, + mood: validated.mood, + musicality: validated.musicality, + }; + if (validated.limit) query.limit = validated.limit; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/similar-artists/by-configurations`, - query, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/similar-artists/by-configurations`, + query, + }); - if ("error" in result) return errorResponse(result.error, result.status); - const data = result.data; - return successResponse({ - artists: Array.isArray(data) ? data : (data as Record)?.data || [], - total: (data as Record)?.total, - }); + if ("error" in result) return errorResponse(result.error, result.status); + const data = result.data; + return successResponse({ + artists: Array.isArray(data) ? data : (data as Record)?.data || [], + total: (data as Record)?.total, + }); + } catch (error) { + console.error("[ERROR] getResearchSimilarHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts index 1a4256b9d..9912bc660 100644 --- a/lib/research/getResearchTracksHandler.ts +++ b/lib/research/getResearchTracksHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -14,16 +14,21 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchTracksHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchTracksHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/tracks`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/tracks`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ tracks: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ tracks: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchTracksHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts index ba04509cd..7e0b512c3 100644 --- a/lib/research/getResearchUrlsHandler.ts +++ b/lib/research/getResearchUrlsHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -15,21 +15,26 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchUrlsHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchUrlsHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/urls`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/urls`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - const data = result.data; - return successResponse({ - urls: Array.isArray(data) - ? data - : Object.entries(data as Record).map(([domain, url]) => ({ domain, url })), - }); + if ("error" in result) return errorResponse(result.error, result.status); + const data = result.data; + return successResponse({ + urls: Array.isArray(data) + ? data + : Object.entries(data as Record).map(([domain, url]) => ({ domain, url })), + }); + } catch (error) { + console.error("[ERROR] getResearchUrlsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts index fd6762776..cf304a725 100644 --- a/lib/research/getResearchVenuesHandler.ts +++ b/lib/research/getResearchVenuesHandler.ts @@ -1,6 +1,6 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { requireArtist } from "@/lib/research/requireArtist"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; @@ -13,16 +13,21 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchVenuesHandler(request: NextRequest) { - const gate = await requireArtist(request); - if (gate instanceof NextResponse) return gate; +export async function getResearchVenuesHandler(request: NextRequest): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - artist: gate.artist, - accountId: gate.accountId, - path: cmId => `/artist/${cmId}/venues`, - }); + const result = await handleArtistResearch({ + artist: validated.artist, + accountId: validated.accountId, + path: cmId => `/artist/${cmId}/venues`, + }); - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ venues: Array.isArray(result.data) ? result.data : [] }); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ venues: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchVenuesHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/requireArtist.ts b/lib/research/validateArtistRequest.ts similarity index 95% rename from lib/research/requireArtist.ts rename to lib/research/validateArtistRequest.ts index 722facf85..303a94e46 100644 --- a/lib/research/requireArtist.ts +++ b/lib/research/validateArtistRequest.ts @@ -9,7 +9,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * * @param request - The incoming HTTP request. */ -export async function requireArtist( +export async function validateArtistRequest( request: NextRequest, ): Promise { const authResult = await validateAuthContext(request); diff --git a/lib/research/validateGetResearchMetricsRequest.ts b/lib/research/validateGetResearchMetricsRequest.ts new file mode 100644 index 000000000..6b9b2728d --- /dev/null +++ b/lib/research/validateGetResearchMetricsRequest.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; + +const VALID_SOURCES = [ + "spotify", + "instagram", + "tiktok", + "twitter", + "facebook", + "youtube_channel", + "youtube_artist", + "soundcloud", + "deezer", + "twitch", + "line", + "melon", + "wikipedia", + "bandsintown", +] as const; + +export type ValidatedGetResearchMetricsRequest = { + accountId: string; + artist: string; + source: string; +}; + +/** + * Validates `GET /api/research/metrics` — auth, `artist`, and `source` enum. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchMetricsRequest( + request: NextRequest, +): Promise { + const source = new URL(request.url).searchParams.get("source"); + if (!source) return errorResponse("source parameter is required", 400); + if (!VALID_SOURCES.includes(source as (typeof VALID_SOURCES)[number])) { + return errorResponse(`Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}`, 400); + } + + const gate = await validateArtistRequest(request); + if (gate instanceof NextResponse) return gate; + + return { ...gate, source }; +} diff --git a/lib/research/validateGetResearchPlaylistsRequest.ts b/lib/research/validateGetResearchPlaylistsRequest.ts new file mode 100644 index 000000000..d731bab3f --- /dev/null +++ b/lib/research/validateGetResearchPlaylistsRequest.ts @@ -0,0 +1,42 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"] as const; +const VALID_STATUSES = ["current", "past"] as const; + +type Platform = (typeof VALID_PLATFORMS)[number]; +type Status = (typeof VALID_STATUSES)[number]; + +export type ValidatedGetResearchPlaylistsRequest = { + accountId: string; + artist: string; + platform: Platform; + status: Status; +}; + +/** + * Validates `GET /api/research/playlists` — auth, `artist`, and + * `platform`/`status` enums (defaults to `spotify` / `current`). + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchPlaylistsRequest( + request: NextRequest, +): Promise { + const { searchParams } = new URL(request.url); + const platform = (searchParams.get("platform") ?? "spotify") as Platform; + const status = (searchParams.get("status") ?? "current") as Status; + + if (!VALID_PLATFORMS.includes(platform)) { + return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); + } + if (!VALID_STATUSES.includes(status)) { + return errorResponse(`Invalid status. Must be one of: ${VALID_STATUSES.join(", ")}`, 400); + } + + const gate = await validateArtistRequest(request); + if (gate instanceof NextResponse) return gate; + + return { ...gate, platform, status }; +} diff --git a/lib/research/validateGetResearchSimilarRequest.ts b/lib/research/validateGetResearchSimilarRequest.ts new file mode 100644 index 000000000..933802780 --- /dev/null +++ b/lib/research/validateGetResearchSimilarRequest.ts @@ -0,0 +1,52 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; + +const VALID_LEVELS = ["high", "medium", "low"] as const; +const AXES = ["audience", "genre", "mood", "musicality"] as const; + +type Level = (typeof VALID_LEVELS)[number]; +type Axis = (typeof AXES)[number]; + +export type ValidatedGetResearchSimilarRequest = { + accountId: string; + artist: string; + audience: Level; + genre: Level; + mood: Level; + musicality: Level; + limit?: string; +}; + +/** + * Validates `GET /api/research/similar` — auth, `artist`, and the four + * configuration axes (`audience`, `genre`, `mood`, `musicality`), each + * defaulting to `medium`. Passes through `limit` as a raw string if present. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchSimilarRequest( + request: NextRequest, +): Promise { + const { searchParams } = new URL(request.url); + const axes: Record = { + audience: "medium", + genre: "medium", + mood: "medium", + musicality: "medium", + }; + + for (const axis of AXES) { + const raw = searchParams.get(axis); + if (raw == null) continue; + if (!VALID_LEVELS.includes(raw as Level)) { + return errorResponse(`Invalid ${axis}. Must be one of: ${VALID_LEVELS.join(", ")}`, 400); + } + axes[axis] = raw as Level; + } + + const gate = await validateArtistRequest(request); + if (gate instanceof NextResponse) return gate; + + return { ...gate, ...axes, limit: searchParams.get("limit") ?? undefined }; +} From 7bed815be2970beedcfa5e2e9f38bf833e2c1104 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:00:01 -0500 Subject: [PATCH 14/28] refactor(kiss): spread validated into handleArtistResearch call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handlers were manually re-assembling { artist, accountId } from validated into handleArtistResearch's params object — pure ceremony since validated already carries exactly those fields. Switch to `{ ...validated, path, ... }` across the 12 simple handlers, and use destructure-into-rest for the 3 that carry endpoint-specific extras. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/research/getResearchAlbumsHandler.ts | 3 +-- lib/research/getResearchAudienceHandler.ts | 3 +-- lib/research/getResearchCareerHandler.ts | 3 +-- lib/research/getResearchCitiesHandler.ts | 3 +-- lib/research/getResearchInsightsHandler.ts | 3 +-- lib/research/getResearchInstagramPostsHandler.ts | 3 +-- lib/research/getResearchMetricsHandler.ts | 6 +++--- lib/research/getResearchMilestonesHandler.ts | 3 +-- lib/research/getResearchPlaylistsHandler.ts | 6 +++--- lib/research/getResearchProfileHandler.ts | 3 +-- lib/research/getResearchRankHandler.ts | 3 +-- lib/research/getResearchSimilarHandler.ts | 14 +++++++------- lib/research/getResearchTracksHandler.ts | 3 +-- lib/research/getResearchUrlsHandler.ts | 3 +-- lib/research/getResearchVenuesHandler.ts | 3 +-- 15 files changed, 25 insertions(+), 37 deletions(-) diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 295abdf6d..8f0376c91 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -20,8 +20,7 @@ export async function getResearchAlbumsHandler(request: NextRequest): Promise `/artist/${cmId}/albums`, }); diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts index 37754607e..732ddf709 100644 --- a/lib/research/getResearchAudienceHandler.ts +++ b/lib/research/getResearchAudienceHandler.ts @@ -24,8 +24,7 @@ export async function getResearchAudienceHandler(request: NextRequest): Promise< const platform = searchParams.get("platform") || "instagram"; const result = await handleArtistResearch({ - artist: validated.artist, - accountId: validated.accountId, + ...validated, path: cmId => `/artist/${cmId}/${platform}-audience-stats`, }); diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts index 543c1c8c5..fd200a591 100644 --- a/lib/research/getResearchCareerHandler.ts +++ b/lib/research/getResearchCareerHandler.ts @@ -20,8 +20,7 @@ export async function getResearchCareerHandler(request: NextRequest): Promise `/artist/${cmId}/career`, }); diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts index f8b52fb04..07860ab77 100644 --- a/lib/research/getResearchCitiesHandler.ts +++ b/lib/research/getResearchCitiesHandler.ts @@ -20,8 +20,7 @@ export async function getResearchCitiesHandler(request: NextRequest): Promise `/artist/${cmId}/where-people-listen`, }); diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts index 8b9b48da4..103aa4099 100644 --- a/lib/research/getResearchInsightsHandler.ts +++ b/lib/research/getResearchInsightsHandler.ts @@ -21,8 +21,7 @@ export async function getResearchInsightsHandler(request: NextRequest): Promise< if (validated instanceof NextResponse) return validated; const result = await handleArtistResearch({ - artist: validated.artist, - accountId: validated.accountId, + ...validated, path: cmId => `/artist/${cmId}/noteworthy-insights`, }); diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index 721817fca..d712d8617 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -23,8 +23,7 @@ export async function getResearchInstagramPostsHandler( if (validated instanceof NextResponse) return validated; const result = await handleArtistResearch({ - artist: validated.artist, - accountId: validated.accountId, + ...validated, path: cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`, }); diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts index d469d60c5..b4351d714 100644 --- a/lib/research/getResearchMetricsHandler.ts +++ b/lib/research/getResearchMetricsHandler.ts @@ -20,10 +20,10 @@ export async function getResearchMetricsHandler(request: NextRequest): Promise `/artist/${cmId}/stat/${validated.source}`, + ...rest, + path: cmId => `/artist/${cmId}/stat/${source}`, }); if ("error" in result) return errorResponse(result.error, result.status); diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts index 41fecc0eb..129f6f98b 100644 --- a/lib/research/getResearchMilestonesHandler.ts +++ b/lib/research/getResearchMilestonesHandler.ts @@ -20,8 +20,7 @@ export async function getResearchMilestonesHandler(request: NextRequest): Promis if (validated instanceof NextResponse) return validated; const result = await handleArtistResearch({ - artist: validated.artist, - accountId: validated.accountId, + ...validated, path: cmId => `/artist/${cmId}/milestones`, }); diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index 5d1bebfb3..b2f486fa5 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -47,10 +47,10 @@ export async function getResearchPlaylistsHandler(request: NextRequest): Promise query.popularIndie = "true"; } + const { platform, status, ...rest } = validated; const result = await handleArtistResearch({ - artist: validated.artist, - accountId: validated.accountId, - path: cmId => `/artist/${cmId}/${validated.platform}/${validated.status}/playlists`, + ...rest, + path: cmId => `/artist/${cmId}/${platform}/${status}/playlists`, query, }); diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts index c6a49e4ed..182b1d099 100644 --- a/lib/research/getResearchProfileHandler.ts +++ b/lib/research/getResearchProfileHandler.ts @@ -20,8 +20,7 @@ export async function getResearchProfileHandler(request: NextRequest): Promise `/artist/${cmId}`, }); diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts index 062641a7e..c234ac555 100644 --- a/lib/research/getResearchRankHandler.ts +++ b/lib/research/getResearchRankHandler.ts @@ -19,8 +19,7 @@ export async function getResearchRankHandler(request: NextRequest): Promise `/artist/${cmId}/artist-rank`, }); diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts index 2707760f0..b68f75a75 100644 --- a/lib/research/getResearchSimilarHandler.ts +++ b/lib/research/getResearchSimilarHandler.ts @@ -21,17 +21,17 @@ export async function getResearchSimilarHandler(request: NextRequest): Promise = { - audience: validated.audience, - genre: validated.genre, - mood: validated.mood, - musicality: validated.musicality, + audience, + genre, + mood, + musicality, }; - if (validated.limit) query.limit = validated.limit; + if (limit) query.limit = limit; const result = await handleArtistResearch({ - artist: validated.artist, - accountId: validated.accountId, + ...rest, path: cmId => `/artist/${cmId}/similar-artists/by-configurations`, query, }); diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts index 9912bc660..0e2900f92 100644 --- a/lib/research/getResearchTracksHandler.ts +++ b/lib/research/getResearchTracksHandler.ts @@ -20,8 +20,7 @@ export async function getResearchTracksHandler(request: NextRequest): Promise `/artist/${cmId}/tracks`, }); diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts index 7e0b512c3..0b772dae3 100644 --- a/lib/research/getResearchUrlsHandler.ts +++ b/lib/research/getResearchUrlsHandler.ts @@ -21,8 +21,7 @@ export async function getResearchUrlsHandler(request: NextRequest): Promise `/artist/${cmId}/urls`, }); diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts index cf304a725..d9550cb50 100644 --- a/lib/research/getResearchVenuesHandler.ts +++ b/lib/research/getResearchVenuesHandler.ts @@ -19,8 +19,7 @@ export async function getResearchVenuesHandler(request: NextRequest): Promise `/artist/${cmId}/venues`, }); From 3d932e9993ffe0ad244fdf2339718cc458c69fbc Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:10:43 -0500 Subject: [PATCH 15/28] refactor: align charts + discover handlers with validator/helper conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New handleResearchProxy({ accountId, path, query?, credits? }) returns { data } | { error, status } — same shape as handleArtistResearch. No auth (validators own that now). - New validateGetResearchChartsRequest.ts and renamed validateDiscoverQuery -> validateGetResearchDiscoverRequest; both now do auth + param validation per the validate*Request convention. - Charts + discover handlers: try/catch, Promise, successResponse / errorResponse, compose validator + proxy helper. - handleResearchRequest is left in place — still used by radio, genres, curator, and festivals handlers (out of scope here). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/handleResearchProxy.test.ts | 73 ++++++++++++++++ .../validateGetResearchChartsRequest.test.ts | 83 +++++++++++++++++++ ...validateGetResearchDiscoverRequest.test.ts | 75 +++++++++++++++++ lib/research/getResearchChartsHandler.ts | 60 +++++++------- lib/research/getResearchDiscoverHandler.ts | 74 +++++++++-------- lib/research/handleResearchProxy.ts | 40 +++++++++ .../validateGetResearchChartsRequest.ts | 40 +++++++++ ... => validateGetResearchDiscoverRequest.ts} | 25 ++++-- 8 files changed, 398 insertions(+), 72 deletions(-) create mode 100644 lib/research/__tests__/handleResearchProxy.test.ts create mode 100644 lib/research/__tests__/validateGetResearchChartsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts create mode 100644 lib/research/handleResearchProxy.ts create mode 100644 lib/research/validateGetResearchChartsRequest.ts rename lib/research/{validateDiscoverQuery.ts => validateGetResearchDiscoverRequest.ts} (57%) diff --git a/lib/research/__tests__/handleResearchProxy.test.ts b/lib/research/__tests__/handleResearchProxy.test.ts new file mode 100644 index 000000000..114649e52 --- /dev/null +++ b/lib/research/__tests__/handleResearchProxy.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { handleResearchProxy } from "../handleResearchProxy"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("handleResearchProxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns { data } on 200 and deducts the default 5 credits", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + status: 200, + data: [{ id: 1 }], + } as never); + vi.mocked(deductCredits).mockResolvedValue(undefined as never); + + const result = await handleResearchProxy({ + accountId: "acc_1", + path: "/charts/spotify", + query: { country_code: "US" }, + }); + + expect(proxyToChartmetric).toHaveBeenCalledWith("/charts/spotify", { country_code: "US" }); + expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5 }); + expect(result).toEqual({ data: [{ id: 1 }] }); + }); + + it("returns { error, status } when proxy is non-200 and skips deduction", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 502, data: null } as never); + + const result = await handleResearchProxy({ + accountId: "acc_1", + path: "/charts/spotify", + }); + + expect(result).toEqual({ error: "Request failed with status 502", status: 502 }); + expect(deductCredits).not.toHaveBeenCalled(); + }); + + it("still returns { data } when credit deduction throws", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(deductCredits).mockRejectedValue(new Error("DB down")); + + const result = await handleResearchProxy({ + accountId: "acc_1", + path: "/x", + }); + + expect(result).toEqual({ data: "ok" }); + }); + + it("respects the credits override", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + vi.mocked(deductCredits).mockResolvedValue(undefined as never); + + await handleResearchProxy({ + accountId: "acc_1", + path: "/x", + credits: 12, + }); + + expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 12 }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchChartsRequest.test.ts b/lib/research/__tests__/validateGetResearchChartsRequest.test.ts new file mode 100644 index 000000000..aeaa9348b --- /dev/null +++ b/lib/research/__tests__/validateGetResearchChartsRequest.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchChartsRequest } from "../validateGetResearchChartsRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchChartsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + + const req = new NextRequest("http://localhost/api/research/charts?platform=spotify"); + const res = await validateGetResearchChartsRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when platform is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/charts"); + const res = await validateGetResearchChartsRequest(req); + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("platform parameter is required"); + }); + + it("returns 400 when platform is not lowercase alpha", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/charts?platform=Spotify/x"); + const res = await validateGetResearchChartsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("Invalid platform"); + }); + + it("fills in defaults when optional params are omitted", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/charts?platform=spotify"); + const res = await validateGetResearchChartsRequest(req); + expect(res).toEqual({ + accountId: "acc_1", + platform: "spotify", + country: "US", + interval: "daily", + type: "regional", + latest: "true", + }); + }); + + it("preserves explicit params over defaults", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/charts?platform=spotify&country=GB&interval=weekly&type=viral&latest=false", + ); + const res = await validateGetResearchChartsRequest(req); + expect(res).toMatchObject({ + country: "GB", + interval: "weekly", + type: "viral", + latest: "false", + }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts b/lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts new file mode 100644 index 000000000..59cc31c07 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchDiscoverRequest } from "../validateGetResearchDiscoverRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchDiscoverRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + + const req = new NextRequest("http://localhost/api/research/discover"); + const res = await validateGetResearchDiscoverRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when country is not 2 letters", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/discover?country=USA"); + const res = await validateGetResearchDiscoverRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("2-letter"); + }); + + it("returns 400 when limit exceeds max", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/discover?limit=500"); + const res = await validateGetResearchDiscoverRequest(req); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns parsed values plus accountId on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/discover?country=US&genre=rock&limit=10&sp_monthly_listeners_min=100&sp_monthly_listeners_max=500", + ); + const res = await validateGetResearchDiscoverRequest(req); + expect(res).toEqual({ + accountId: "acc_1", + country: "US", + genre: "rock", + limit: 10, + sp_monthly_listeners_min: 100, + sp_monthly_listeners_max: 500, + }); + }); + + it("returns just accountId when no params are provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/discover"); + const res = await validateGetResearchDiscoverRequest(req); + expect(res).toEqual({ accountId: "acc_1" }); + }); +}); diff --git a/lib/research/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts index 8de47afa3..2672021b6 100644 --- a/lib/research/getResearchChartsHandler.ts +++ b/lib/research/getResearchChartsHandler.ts @@ -1,44 +1,44 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearchProxy } from "@/lib/research/handleResearchProxy"; +import { validateGetResearchChartsRequest } from "@/lib/research/validateGetResearchChartsRequest"; /** * GET /api/research/charts * * Returns global chart positions for a platform. Not artist-scoped. - * Requires `platform` query param. Optional: `country`, `interval`, `type`. + * Requires `platform` query param. Optional: `country`, `interval`, `type`, `latest`. * * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchChartsHandler(request: NextRequest) { - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform"); +export async function getResearchChartsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchChartsRequest(request); + if (validated instanceof NextResponse) return validated; - if (!platform) { - return NextResponse.json( - { status: "error", error: "platform parameter is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + const result = await handleResearchProxy({ + accountId: validated.accountId, + path: `/charts/${validated.platform}`, + query: { + country_code: validated.country, + interval: validated.interval, + type: validated.type, + latest: validated.latest, + }, + }); - if (!/^[a-z]+$/.test(platform)) { - return NextResponse.json( - { status: "error", error: "Invalid platform parameter" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + if ("error" in result) return errorResponse(result.error, result.status); - return handleResearchRequest( - request, - () => `/charts/${platform}`, - sp => { - const params: Record = {}; - params.country_code = sp.get("country") || "US"; - params.interval = sp.get("interval") || "daily"; - params.type = sp.get("type") || "regional"; - params.latest = sp.get("latest") ?? "true"; - return params; - }, - ); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchChartsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts index fc114c049..8cb7e2c71 100644 --- a/lib/research/getResearchDiscoverHandler.ts +++ b/lib/research/getResearchDiscoverHandler.ts @@ -1,41 +1,49 @@ import { type NextRequest, NextResponse } from "next/server"; -import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; -import { validateDiscoverQuery } from "@/lib/research/validateDiscoverQuery"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearchProxy } from "@/lib/research/handleResearchProxy"; +import { validateGetResearchDiscoverRequest } from "@/lib/research/validateGetResearchDiscoverRequest"; /** - * Discover handler — filters artists by country, genre, listener ranges, growth rate. + * GET /api/research/discover * - * @param request - query params: country, genre, sort, limit, sp_monthly_listeners_min/max - * @returns JSON artist list or error + * Filters artists by country, genre, listener ranges, and growth rate. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. */ -export async function getResearchDiscoverHandler(request: NextRequest) { - const { searchParams } = new URL(request.url); - const validated = validateDiscoverQuery(searchParams); +export async function getResearchDiscoverHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchDiscoverRequest(request); + if (validated instanceof NextResponse) return validated; + + const query: Record = {}; + if (validated.country) query.code2 = validated.country; + if (validated.genre) query.tagId = validated.genre; + if (validated.sort) query.sortColumn = validated.sort; + if (validated.limit !== undefined) query.limit = String(validated.limit); + if ( + validated.sp_monthly_listeners_min !== undefined && + validated.sp_monthly_listeners_max !== undefined + ) { + query["sp_ml[]"] = + `${validated.sp_monthly_listeners_min},${validated.sp_monthly_listeners_max}`; + } else if (validated.sp_monthly_listeners_min !== undefined) { + query["sp_ml[]"] = String(validated.sp_monthly_listeners_min); + } else if (validated.sp_monthly_listeners_max !== undefined) { + query["sp_ml[]"] = String(validated.sp_monthly_listeners_max); + } - if (validated instanceof NextResponse) return validated; + const result = await handleResearchProxy({ + accountId: validated.accountId, + path: "/artist/list/filter", + query, + }); - return handleResearchRequest( - request, - () => "/artist/list/filter", - () => { - const params: Record = {}; - if (validated.country) params.code2 = validated.country; - if (validated.genre) params.tagId = validated.genre; - if (validated.sort) params.sortColumn = validated.sort; - if (validated.limit) params.limit = String(validated.limit); - if ( - validated.sp_monthly_listeners_min !== undefined && - validated.sp_monthly_listeners_max !== undefined - ) { - params["sp_ml[]"] = - `${validated.sp_monthly_listeners_min},${validated.sp_monthly_listeners_max}`; - } else if (validated.sp_monthly_listeners_min !== undefined) { - params["sp_ml[]"] = String(validated.sp_monthly_listeners_min); - } else if (validated.sp_monthly_listeners_max !== undefined) { - params["sp_ml[]"] = String(validated.sp_monthly_listeners_max); - } - return params; - }, - data => ({ artists: Array.isArray(data) ? data : [] }), - ); + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ artists: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchDiscoverHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/handleResearchProxy.ts b/lib/research/handleResearchProxy.ts new file mode 100644 index 000000000..8591e3862 --- /dev/null +++ b/lib/research/handleResearchProxy.ts @@ -0,0 +1,40 @@ +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +export type HandleResearchProxyParams = { + accountId: string; + path: string; + query?: Record; + /** Credits to charge on success. Defaults to 5. */ + credits?: number; +}; + +export type HandleResearchProxyResult = { data: unknown } | { error: string; status: number }; + +/** + * Proxies a non-artist-scoped research call to Chartmetric and deducts credits + * on success. Credit-deduction failures are non-fatal — the fetched data is + * still returned so transient billing failures don't block read endpoints. + * + * Auth is intentionally out of scope here — callers (validators) own that. + * + * @returns `{ data }` on success, `{ error, status }` on upstream failure. + */ +export async function handleResearchProxy( + params: HandleResearchProxyParams, +): Promise { + const { accountId, path, query, credits = 5 } = params; + + const result = await proxyToChartmetric(path, query); + if (result.status !== 200) { + return { error: `Request failed with status ${result.status}`, status: result.status }; + } + + try { + await deductCredits({ accountId, creditsToDeduct: credits }); + } catch (error) { + console.error("[research] credit deduction failed:", error); + } + + return { data: result.data }; +} diff --git a/lib/research/validateGetResearchChartsRequest.ts b/lib/research/validateGetResearchChartsRequest.ts new file mode 100644 index 000000000..391e052ff --- /dev/null +++ b/lib/research/validateGetResearchChartsRequest.ts @@ -0,0 +1,40 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +export type ValidatedGetResearchChartsRequest = { + accountId: string; + platform: string; + country: string; + interval: string; + type: string; + latest: string; +}; + +/** + * Validates `GET /api/research/charts` — auth + `platform` (required, lowercase + * alpha) + defaults for `country` ("US"), `interval` ("daily"), `type` + * ("regional"), and `latest` ("true"). + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchChartsRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + if (!platform) return errorResponse("platform parameter is required", 400); + if (!/^[a-z]+$/.test(platform)) return errorResponse("Invalid platform parameter", 400); + + return { + accountId: authResult.accountId, + platform, + country: searchParams.get("country") || "US", + interval: searchParams.get("interval") || "daily", + type: searchParams.get("type") || "regional", + latest: searchParams.get("latest") ?? "true", + }; +} diff --git a/lib/research/validateDiscoverQuery.ts b/lib/research/validateGetResearchDiscoverRequest.ts similarity index 57% rename from lib/research/validateDiscoverQuery.ts rename to lib/research/validateGetResearchDiscoverRequest.ts index f0a548837..af0cfb364 100644 --- a/lib/research/validateDiscoverQuery.ts +++ b/lib/research/validateGetResearchDiscoverRequest.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { z } from "zod"; export const discoverQuerySchema = z.object({ @@ -13,10 +14,20 @@ export const discoverQuerySchema = z.object({ export type DiscoverQuery = z.infer; +export type ValidatedGetResearchDiscoverRequest = DiscoverQuery & { accountId: string }; + /** - * Validates query params for GET /api/research/discover. + * Validates `GET /api/research/discover` — auth + filter query params. + * + * @param request - The incoming HTTP request. */ -export function validateDiscoverQuery(searchParams: URLSearchParams): NextResponse | DiscoverQuery { +export async function validateGetResearchDiscoverRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); const raw: Record = {}; for (const key of [ "country", @@ -31,17 +42,13 @@ export function validateDiscoverQuery(searchParams: URLSearchParams): NextRespon } const result = discoverQuerySchema.safeParse(raw); - if (!result.success) { const firstError = result.error.issues[0]; return NextResponse.json( - { - status: "error", - error: firstError.message, - }, + { status: "error", error: firstError.message }, { status: 400, headers: getCorsHeaders() }, ); } - return result.data; + return { accountId: authResult.accountId, ...result.data }; } From 99bb54350d3fe386437b70be3c5a7115f96e91f1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:17:42 -0500 Subject: [PATCH 16/28] refactor: port final 4 research handlers; delete handleResearchRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getResearchRadioHandler - getResearchGenresHandler - getResearchCuratorHandler - getResearchFestivalsHandler (non-artist-scoped: /festival/list is a global Chartmetric endpoint, so it uses handleResearchProxy) Each now uses its own validateGetRequest validator and handleResearchProxy. None of the four were artist-scoped — all four hit global Chartmetric paths (/radio/station-list, /genres, /curator/{platform}/{id}, /festival/list). Deletes the last template-method orchestrator. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../validateGetResearchCuratorRequest.test.ts | 55 ++++++++++++++++++ ...alidateGetResearchFestivalsRequest.test.ts | 37 ++++++++++++ .../validateGetResearchGenresRequest.test.ts | 37 ++++++++++++ .../validateGetResearchRadioRequest.test.ts | 38 +++++++++++++ lib/research/getResearchCuratorHandler.ts | 42 +++++++++----- lib/research/getResearchFestivalsHandler.ts | 33 +++++++---- lib/research/getResearchGenresHandler.ts | 32 +++++++---- lib/research/getResearchRadioHandler.ts | 32 +++++++---- lib/research/handleResearchRequest.ts | 57 ------------------- .../validateGetResearchCuratorRequest.ts | 32 +++++++++++ .../validateGetResearchFestivalsRequest.ts | 20 +++++++ .../validateGetResearchGenresRequest.ts | 20 +++++++ .../validateGetResearchRadioRequest.ts | 20 +++++++ 13 files changed, 353 insertions(+), 102 deletions(-) create mode 100644 lib/research/__tests__/validateGetResearchCuratorRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchGenresRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchRadioRequest.test.ts delete mode 100644 lib/research/handleResearchRequest.ts create mode 100644 lib/research/validateGetResearchCuratorRequest.ts create mode 100644 lib/research/validateGetResearchFestivalsRequest.ts create mode 100644 lib/research/validateGetResearchGenresRequest.ts create mode 100644 lib/research/validateGetResearchRadioRequest.ts diff --git a/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts new file mode 100644 index 000000000..949b7d374 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchCuratorRequest } from "../validateGetResearchCuratorRequest"; +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(), +})); + +describe("validateGetResearchCuratorRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "t", + }); + }); + + it("returns auth error when auth fails", async () => { + const errResp = NextResponse.json({ error: "no" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errResp); + const result = await validateGetResearchCuratorRequest( + new NextRequest("http://localhost/api/research/curator?platform=spotify&id=123"), + ); + expect(result).toBe(errResp); + }); + + it("returns 400 when platform is missing", async () => { + const result = await validateGetResearchCuratorRequest( + new NextRequest("http://localhost/api/research/curator?id=123"), + ); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when id is missing", async () => { + const result = await validateGetResearchCuratorRequest( + new NextRequest("http://localhost/api/research/curator?platform=spotify"), + ); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns accountId, platform, id on success", async () => { + const result = await validateGetResearchCuratorRequest( + new NextRequest("http://localhost/api/research/curator?platform=spotify&id=abc123"), + ); + expect(result).toEqual({ accountId: "acc-1", platform: "spotify", id: "abc123" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts b/lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts new file mode 100644 index 000000000..e11b7147a --- /dev/null +++ b/lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchFestivalsRequest } from "../validateGetResearchFestivalsRequest"; +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(), +})); + +describe("validateGetResearchFestivalsRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns auth error when auth fails", async () => { + const errResp = NextResponse.json({ error: "no" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errResp); + const result = await validateGetResearchFestivalsRequest( + new NextRequest("http://localhost/api/research/festivals"), + ); + expect(result).toBe(errResp); + }); + + it("returns accountId on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "t", + }); + const result = await validateGetResearchFestivalsRequest( + new NextRequest("http://localhost/api/research/festivals"), + ); + expect(result).toEqual({ accountId: "acc-1" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchGenresRequest.test.ts b/lib/research/__tests__/validateGetResearchGenresRequest.test.ts new file mode 100644 index 000000000..d9ca26883 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchGenresRequest.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchGenresRequest } from "../validateGetResearchGenresRequest"; +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(), +})); + +describe("validateGetResearchGenresRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns auth error when auth fails", async () => { + const errResp = NextResponse.json({ error: "no" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errResp); + const result = await validateGetResearchGenresRequest( + new NextRequest("http://localhost/api/research/genres"), + ); + expect(result).toBe(errResp); + }); + + it("returns accountId on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "t", + }); + const result = await validateGetResearchGenresRequest( + new NextRequest("http://localhost/api/research/genres"), + ); + expect(result).toEqual({ accountId: "acc-1" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchRadioRequest.test.ts b/lib/research/__tests__/validateGetResearchRadioRequest.test.ts new file mode 100644 index 000000000..3a5430425 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchRadioRequest.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchRadioRequest } from "../validateGetResearchRadioRequest"; +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(), +})); + +describe("validateGetResearchRadioRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns auth error when auth fails", async () => { + const errResp = NextResponse.json({ error: "no" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errResp); + + const result = await validateGetResearchRadioRequest( + new NextRequest("http://localhost/api/research/radio"), + ); + expect(result).toBe(errResp); + }); + + it("returns accountId on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "t", + }); + const result = await validateGetResearchRadioRequest( + new NextRequest("http://localhost/api/research/radio"), + ); + expect(result).toEqual({ accountId: "acc-1" }); + }); +}); diff --git a/lib/research/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts index 4c8d47bd0..7fc19508b 100644 --- a/lib/research/getResearchCuratorHandler.ts +++ b/lib/research/getResearchCuratorHandler.ts @@ -1,26 +1,38 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearchProxy } from "@/lib/research/handleResearchProxy"; +import { validateGetResearchCuratorRequest } from "@/lib/research/validateGetResearchCuratorRequest"; /** * GET /api/research/curator * - * Returns details for a specific playlist curator. + * Returns details for a specific playlist curator. Not artist-scoped — keyed by + * `platform` and `id` query params, proxied to `/curator/{platform}/{id}`. * - * @param request - Requires `platform` and `id` query params + * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchCuratorHandler(request: NextRequest) { - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform"); - const id = searchParams.get("id"); +export async function getResearchCuratorHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchCuratorRequest(request); + if (validated instanceof NextResponse) return validated; - if (!platform || !id) { - return NextResponse.json( - { status: "error", error: "platform and id parameters are required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + const result = await handleResearchProxy({ + accountId: validated.accountId, + path: `/curator/${validated.platform}/${validated.id}`, + }); + + if ("error" in result) return errorResponse(result.error, result.status); - return handleResearchRequest(request, () => `/curator/${platform}/${id}`); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchCuratorHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts index f390e86c7..2fb3d28ea 100644 --- a/lib/research/getResearchFestivalsHandler.ts +++ b/lib/research/getResearchFestivalsHandler.ts @@ -1,19 +1,32 @@ -import { type NextRequest } from "next/server"; -import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearchProxy } from "@/lib/research/handleResearchProxy"; +import { validateGetResearchFestivalsRequest } from "@/lib/research/validateGetResearchFestivalsRequest"; /** * GET /api/research/festivals * - * Returns a list of music festivals. + * Returns a list of music festivals. Not artist-scoped — `/festival/list` is a + * global Chartmetric endpoint, so this uses `handleResearchProxy`. * * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchFestivalsHandler(request: NextRequest) { - return handleResearchRequest( - request, - () => "/festival/list", - undefined, - data => ({ festivals: Array.isArray(data) ? data : [] }), - ); +export async function getResearchFestivalsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchFestivalsRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearchProxy({ + accountId: validated.accountId, + path: "/festival/list", + }); + + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ festivals: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchFestivalsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchGenresHandler.ts b/lib/research/getResearchGenresHandler.ts index 14e32d418..a78508c66 100644 --- a/lib/research/getResearchGenresHandler.ts +++ b/lib/research/getResearchGenresHandler.ts @@ -1,19 +1,31 @@ -import { type NextRequest } from "next/server"; -import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearchProxy } from "@/lib/research/handleResearchProxy"; +import { validateGetResearchGenresRequest } from "@/lib/research/validateGetResearchGenresRequest"; /** * GET /api/research/genres * - * Returns all available genre IDs and names. + * Returns all available genre IDs and names. Not artist-scoped. * * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchGenresHandler(request: NextRequest) { - return handleResearchRequest( - request, - () => "/genres", - undefined, - data => ({ genres: Array.isArray(data) ? data : [] }), - ); +export async function getResearchGenresHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchGenresRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearchProxy({ + accountId: validated.accountId, + path: "/genres", + }); + + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ genres: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchGenresHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts index 246c51c45..a4e2aed60 100644 --- a/lib/research/getResearchRadioHandler.ts +++ b/lib/research/getResearchRadioHandler.ts @@ -1,19 +1,31 @@ -import { type NextRequest } from "next/server"; -import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearchProxy } from "@/lib/research/handleResearchProxy"; +import { validateGetResearchRadioRequest } from "@/lib/research/validateGetResearchRadioRequest"; /** * GET /api/research/radio * - * Returns a list of radio stations. + * Returns a list of radio stations. Not artist-scoped. * * @param request - The incoming HTTP request. * @returns The JSON response. */ -export async function getResearchRadioHandler(request: NextRequest) { - return handleResearchRequest( - request, - () => "/radio/station-list", - undefined, - data => ({ stations: Array.isArray(data) ? data : [] }), - ); +export async function getResearchRadioHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchRadioRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearchProxy({ + accountId: validated.accountId, + path: "/radio/station-list", + }); + + if ("error" in result) return errorResponse(result.error, result.status); + return successResponse({ stations: Array.isArray(result.data) ? result.data : [] }); + } catch (error) { + console.error("[ERROR] getResearchRadioHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/handleResearchRequest.ts b/lib/research/handleResearchRequest.ts deleted file mode 100644 index fd4384f25..000000000 --- a/lib/research/handleResearchRequest.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; - -/** - * Shared handler for non-artist-scoped research endpoints. Handles auth, credit deduction, and proxying to Chartmetric. - * - * @param request - incoming HTTP request - * @param buildPath - returns the Chartmetric API path - * @param getQueryParams - extracts query params from the request - * @param transformResponse - reshapes the proxy response data - * @param credits - credits to deduct (default 5) - * @returns JSON response with data or error - */ -export async function handleResearchRequest( - request: NextRequest, - buildPath: () => string, - getQueryParams?: (searchParams: URLSearchParams) => Record, - transformResponse?: (data: unknown) => unknown, - credits: number = 5, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - const { searchParams } = new URL(request.url); - const path = buildPath(); - const queryParams = getQueryParams ? getQueryParams(searchParams) : undefined; - const result = await proxyToChartmetric(path, queryParams); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: `Request failed with status ${result.status}` }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - - try { - await deductCredits({ accountId, creditsToDeduct: credits }); - } catch { - // Credit deduction failed but data was fetched — log but don't block - } - - const responseData = transformResponse ? transformResponse(result.data) : result.data; - - return NextResponse.json( - { - status: "success", - ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) - ? responseData - : { data: responseData }), - }, - { status: 200, headers: getCorsHeaders() }, - ); -} diff --git a/lib/research/validateGetResearchCuratorRequest.ts b/lib/research/validateGetResearchCuratorRequest.ts new file mode 100644 index 000000000..c039cb932 --- /dev/null +++ b/lib/research/validateGetResearchCuratorRequest.ts @@ -0,0 +1,32 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +export type ValidatedGetResearchCuratorRequest = { + accountId: string; + platform: string; + id: string; +}; + +/** + * Validates `GET /api/research/curator` — auth + required `platform` and `id` + * query params. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchCuratorRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + const id = searchParams.get("id"); + + if (!platform || !id) { + return errorResponse("platform and id parameters are required", 400); + } + + return { accountId: authResult.accountId, platform, id }; +} diff --git a/lib/research/validateGetResearchFestivalsRequest.ts b/lib/research/validateGetResearchFestivalsRequest.ts new file mode 100644 index 000000000..a9cba13c1 --- /dev/null +++ b/lib/research/validateGetResearchFestivalsRequest.ts @@ -0,0 +1,20 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +export type ValidatedGetResearchFestivalsRequest = { + accountId: string; +}; + +/** + * Validates `GET /api/research/festivals` — auth only. No required query params. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchFestivalsRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + return { accountId: authResult.accountId }; +} diff --git a/lib/research/validateGetResearchGenresRequest.ts b/lib/research/validateGetResearchGenresRequest.ts new file mode 100644 index 000000000..1b5183d7b --- /dev/null +++ b/lib/research/validateGetResearchGenresRequest.ts @@ -0,0 +1,20 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +export type ValidatedGetResearchGenresRequest = { + accountId: string; +}; + +/** + * Validates `GET /api/research/genres` — auth only. No required query params. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchGenresRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + return { accountId: authResult.accountId }; +} diff --git a/lib/research/validateGetResearchRadioRequest.ts b/lib/research/validateGetResearchRadioRequest.ts new file mode 100644 index 000000000..b45c87e6c --- /dev/null +++ b/lib/research/validateGetResearchRadioRequest.ts @@ -0,0 +1,20 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +export type ValidatedGetResearchRadioRequest = { + accountId: string; +}; + +/** + * Validates `GET /api/research/radio` — auth only. No required query params. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchRadioRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + return { accountId: authResult.accountId }; +} From 218d4ed5dc7ced14944a6c60a1863efac0a4712c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:24:48 -0500 Subject: [PATCH 17/28] =?UTF-8?q?refactor(kiss):=20rename=20handleResearch?= =?UTF-8?q?Proxy=20=E2=86=92=20handleResearch,=20proxyToChartmetric=20?= =?UTF-8?q?=E2=86=92=20fetchChartmetric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the "proxy" terminology per review feedback: - handleResearchProxy → handleResearch - proxyToChartmetric → fetchChartmetric Renames the symbols, file names, and test file names to match. No behavior change. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...etric.test.ts => fetchChartmetric.test.ts} | 16 +++++------ .../getResearchChartsHandler.test.ts | 14 +++++----- .../getResearchDiscoverHandler.test.ts | 14 +++++----- .../getResearchLookupHandler.test.ts | 10 +++---- .../getResearchMetricsHandler.test.ts | 4 +-- .../getResearchSearchHandler.test.ts | 8 +++--- .../getResearchSimilarHandler.test.ts | 18 ++++++------ .../__tests__/getResearchTrackHandler.test.ts | 8 +++--- .../getResearchTrackPlaylistsHandler.test.ts | 12 ++++---- .../__tests__/handleArtistResearch.test.ts | 22 +++++++-------- ...chProxy.test.ts => handleResearch.test.ts} | 28 +++++++++---------- lib/research/__tests__/resolveArtist.test.ts | 16 +++++------ ...xyToChartmetric.ts => fetchChartmetric.ts} | 2 +- lib/research/getResearchChartsHandler.ts | 4 +-- lib/research/getResearchCuratorHandler.ts | 4 +-- lib/research/getResearchDiscoverHandler.ts | 4 +-- lib/research/getResearchFestivalsHandler.ts | 6 ++-- lib/research/getResearchGenresHandler.ts | 4 +-- lib/research/getResearchLookupHandler.ts | 4 +-- lib/research/getResearchPlaylistHandler.ts | 6 ++-- lib/research/getResearchRadioHandler.ts | 4 +-- lib/research/getResearchSearchHandler.ts | 4 +-- lib/research/getResearchTrackHandler.ts | 6 ++-- .../getResearchTrackPlaylistsHandler.ts | 4 +-- lib/research/handleArtistResearch.ts | 4 +-- ...ndleResearchProxy.ts => handleResearch.ts} | 12 ++++---- lib/research/resolveArtist.ts | 4 +-- lib/research/resolveTrack.ts | 6 ++-- 28 files changed, 123 insertions(+), 125 deletions(-) rename lib/research/__tests__/{proxyToChartmetric.test.ts => fetchChartmetric.test.ts} (82%) rename lib/research/__tests__/{handleResearchProxy.test.ts => handleResearch.test.ts} (64%) rename lib/research/{proxyToChartmetric.ts => fetchChartmetric.ts} (97%) rename lib/research/{handleResearchProxy.ts => handleResearch.ts} (72%) diff --git a/lib/research/__tests__/proxyToChartmetric.test.ts b/lib/research/__tests__/fetchChartmetric.test.ts similarity index 82% rename from lib/research/__tests__/proxyToChartmetric.test.ts rename to lib/research/__tests__/fetchChartmetric.test.ts index 4e419ca17..30142bc46 100644 --- a/lib/research/__tests__/proxyToChartmetric.test.ts +++ b/lib/research/__tests__/fetchChartmetric.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { proxyToChartmetric } from "../proxyToChartmetric"; +import { fetchChartmetric } from "../fetchChartmetric"; vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ getChartmetricToken: vi.fn().mockResolvedValue("mock-token"), @@ -7,7 +7,7 @@ vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ const mockFetch = vi.fn(); -describe("proxyToChartmetric", () => { +describe("fetchChartmetric", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); @@ -20,7 +20,7 @@ describe("proxyToChartmetric", () => { json: async () => ({ obj: { name: "Drake", id: 3380 } }), } as Response); - const result = await proxyToChartmetric("/artist/3380"); + const result = await fetchChartmetric("/artist/3380"); expect(result.data).toEqual({ name: "Drake", id: 3380 }); expect(result.status).toBe(200); @@ -33,7 +33,7 @@ describe("proxyToChartmetric", () => { json: async () => ({ results: [{ name: "Drake" }] }), } as Response); - const result = await proxyToChartmetric("/search", { q: "Drake" }); + const result = await fetchChartmetric("/search", { q: "Drake" }); expect(result.data).toEqual({ results: [{ name: "Drake" }] }); }); @@ -45,7 +45,7 @@ describe("proxyToChartmetric", () => { json: async () => ({ obj: [] }), } as Response); - await proxyToChartmetric("/search", { q: "Drake", type: "artists" }); + await fetchChartmetric("/search", { q: "Drake", type: "artists" }); const calledUrl = mockFetch.mock.calls[0][0]; expect(calledUrl).toContain("q=Drake"); @@ -59,7 +59,7 @@ describe("proxyToChartmetric", () => { json: async () => ({ obj: {} }), } as Response); - await proxyToChartmetric("/artist/3380"); + await fetchChartmetric("/artist/3380"); const calledOpts = mockFetch.mock.calls[0][1]; expect(calledOpts.headers).toMatchObject({ Authorization: "Bearer mock-token" }); @@ -71,7 +71,7 @@ describe("proxyToChartmetric", () => { status: 404, } as Response); - const result = await proxyToChartmetric("/artist/99999"); + const result = await fetchChartmetric("/artist/99999"); expect(result.status).toBe(404); expect(result.data).toEqual({ error: "Chartmetric API returned 404" }); @@ -84,7 +84,7 @@ describe("proxyToChartmetric", () => { json: async () => ({ obj: [] }), } as Response); - await proxyToChartmetric("/search", { q: "Drake", type: "" }); + await fetchChartmetric("/search", { q: "Drake", type: "" }); const calledUrl = mockFetch.mock.calls[0][0]; expect(calledUrl).toContain("q=Drake"); diff --git a/lib/research/__tests__/getResearchChartsHandler.test.ts b/lib/research/__tests__/getResearchChartsHandler.test.ts index 5ecf99e65..10e1f7229 100644 --- a/lib/research/__tests__/getResearchChartsHandler.test.ts +++ b/lib/research/__tests__/getResearchChartsHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; import { getResearchChartsHandler } from "../getResearchChartsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,8 +13,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ @@ -52,7 +52,7 @@ describe("getResearchChartsHandler", () => { }); it("defaults type to 'regional' and interval to 'daily'", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: { chart: [] }, status: 200, }); @@ -60,14 +60,14 @@ describe("getResearchChartsHandler", () => { const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&country=US"); await getResearchChartsHandler(req); - const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + const calledParams = vi.mocked(fetchChartmetric).mock.calls[0][1]; expect(calledParams).toHaveProperty("type", "regional"); expect(calledParams).toHaveProperty("interval", "daily"); expect(calledParams).toHaveProperty("country_code", "US"); }); it("preserves user-provided type and interval", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: { chart: [] }, status: 200, }); @@ -77,7 +77,7 @@ describe("getResearchChartsHandler", () => { ); await getResearchChartsHandler(req); - const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + const calledParams = vi.mocked(fetchChartmetric).mock.calls[0][1]; expect(calledParams).toMatchObject({ type: "viral", interval: "weekly" }); }); }); diff --git a/lib/research/__tests__/getResearchDiscoverHandler.test.ts b/lib/research/__tests__/getResearchDiscoverHandler.test.ts index 783301c41..b7c3cf34d 100644 --- a/lib/research/__tests__/getResearchDiscoverHandler.test.ts +++ b/lib/research/__tests__/getResearchDiscoverHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchDiscoverHandler } from "../getResearchDiscoverHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,8 +13,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ @@ -75,7 +75,7 @@ describe("getResearchDiscoverHandler", () => { ? Exclude : never); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: [ { name: "Artist A", sp_monthly_listeners: 100000 }, { name: "Artist B", sp_monthly_listeners: 200000 }, @@ -101,7 +101,7 @@ describe("getResearchDiscoverHandler", () => { ? Exclude : never); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: [], status: 200, }); @@ -111,7 +111,7 @@ describe("getResearchDiscoverHandler", () => { ); await getResearchDiscoverHandler(req); - expect(proxyToChartmetric).toHaveBeenCalledWith( + expect(fetchChartmetric).toHaveBeenCalledWith( "/artist/list/filter", expect.objectContaining({ "sp_ml[]": "50000,200000" }), ); @@ -126,7 +126,7 @@ describe("getResearchDiscoverHandler", () => { ? Exclude : never); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: null, status: 500, }); diff --git a/lib/research/__tests__/getResearchLookupHandler.test.ts b/lib/research/__tests__/getResearchLookupHandler.test.ts index 0a47b4585..15cb42d67 100644 --- a/lib/research/__tests__/getResearchLookupHandler.test.ts +++ b/lib/research/__tests__/getResearchLookupHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; import { getResearchLookupHandler } from "../getResearchLookupHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,8 +13,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ @@ -51,7 +51,7 @@ describe("getResearchLookupHandler", () => { { id: 2, platform: "apple_music" }, ]; - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: arrayData, status: 200, }); @@ -72,7 +72,7 @@ describe("getResearchLookupHandler", () => { it("spreads object responses normally", async () => { const objectData = { id: 3380, spotify_id: "3TVXtAsR1Inumwj472S9r4" }; - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: objectData, status: 200, }); diff --git a/lib/research/__tests__/getResearchMetricsHandler.test.ts b/lib/research/__tests__/getResearchMetricsHandler.test.ts index 709b8bdd8..f8efdf5cb 100644 --- a/lib/research/__tests__/getResearchMetricsHandler.test.ts +++ b/lib/research/__tests__/getResearchMetricsHandler.test.ts @@ -16,8 +16,8 @@ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts index 35528954f..66edd682f 100644 --- a/lib/research/__tests__/getResearchSearchHandler.test.ts +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchSearchHandler } from "../getResearchSearchHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,8 +13,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ @@ -56,7 +56,7 @@ describe("getResearchSearchHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: { artists: [{ name: "Drake", id: 3380 }] }, status: 200, }); diff --git a/lib/research/__tests__/getResearchSimilarHandler.test.ts b/lib/research/__tests__/getResearchSimilarHandler.test.ts index b9230e2b6..509797a50 100644 --- a/lib/research/__tests__/getResearchSimilarHandler.test.ts +++ b/lib/research/__tests__/getResearchSimilarHandler.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from "next/server"; import { getResearchSimilarHandler } from "../getResearchSimilarHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -18,8 +18,8 @@ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ @@ -38,7 +38,7 @@ describe("getResearchSimilarHandler", () => { }); it("uses by-configurations path with default params when no config params provided", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: [{ id: 100, name: "Kendrick Lamar" }], status: 200, }); @@ -48,13 +48,13 @@ describe("getResearchSimilarHandler", () => { expect(res.status).toBe(200); // Should call by-configurations, NOT relatedartists - const calledPath = vi.mocked(proxyToChartmetric).mock.calls[0][0]; + const calledPath = vi.mocked(fetchChartmetric).mock.calls[0][0]; expect(calledPath).toContain("by-configurations"); expect(calledPath).not.toContain("relatedartists"); }); it("uses by-configurations path when config params are provided", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: [{ id: 100, name: "Kendrick Lamar" }], status: 200, }); @@ -63,12 +63,12 @@ describe("getResearchSimilarHandler", () => { const res = await getResearchSimilarHandler(req); expect(res.status).toBe(200); - const calledPath = vi.mocked(proxyToChartmetric).mock.calls[0][0]; + const calledPath = vi.mocked(fetchChartmetric).mock.calls[0][0]; expect(calledPath).toContain("by-configurations"); }); it("passes default medium values for config params when none provided", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: [], status: 200, }); @@ -76,7 +76,7 @@ describe("getResearchSimilarHandler", () => { const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); await getResearchSimilarHandler(req); - const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + const calledParams = vi.mocked(fetchChartmetric).mock.calls[0][1]; expect(calledParams).toMatchObject({ audience: "medium", genre: "medium", diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts index c1ffe96b0..a1914f9af 100644 --- a/lib/research/__tests__/getResearchTrackHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackHandler } from "../getResearchTrackHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,8 +13,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ @@ -56,7 +56,7 @@ describe("getResearchTrackHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric) + vi.mocked(fetchChartmetric) .mockResolvedValueOnce({ data: { tracks: [{ id: 12345 }] }, status: 200, diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 60df198ee..6d0b7a4ae 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; import { resolveTrack } from "@/lib/research/resolveTrack"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -14,8 +14,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/research/resolveTrack", () => ({ @@ -93,7 +93,7 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: [ { playlist: { @@ -127,7 +127,7 @@ describe("getResearchTrackPlaylistsHandler", () => { vi.mocked(resolveTrack).mockResolvedValue({ id: "18220712" }); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: [ { playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, @@ -171,7 +171,7 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: null, status: 200, }); diff --git a/lib/research/__tests__/handleArtistResearch.test.ts b/lib/research/__tests__/handleArtistResearch.test.ts index 79e656c7b..e8f01fca9 100644 --- a/lib/research/__tests__/handleArtistResearch.test.ts +++ b/lib/research/__tests__/handleArtistResearch.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleArtistResearch } from "../handleArtistResearch"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ deductCredits: vi.fn(), @@ -30,13 +30,13 @@ describe("handleArtistResearch", () => { }); expect(result).toEqual({ error: "Artist not found", status: 404 }); - expect(proxyToChartmetric).not.toHaveBeenCalled(); + expect(fetchChartmetric).not.toHaveBeenCalled(); expect(deductCredits).not.toHaveBeenCalled(); }); it("proxies to the built path and returns { data } on success", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: 42 } as never); - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: [{ name: "a" }], } as never); @@ -48,14 +48,14 @@ describe("handleArtistResearch", () => { path: id => `/artist/${id}/albums`, }); - expect(proxyToChartmetric).toHaveBeenCalledWith("/artist/42/albums", undefined); + expect(fetchChartmetric).toHaveBeenCalledWith("/artist/42/albums", undefined); expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5 }); expect(result).toEqual({ data: [{ name: "a" }] }); }); - it("forwards query params to proxyToChartmetric", async () => { + it("forwards query params to fetchChartmetric", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: 7 } as never); - vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: {} } as never); await handleArtistResearch({ artist: "X", @@ -64,7 +64,7 @@ describe("handleArtistResearch", () => { query: { limit: "10", platform: "spotify" }, }); - expect(proxyToChartmetric).toHaveBeenCalledWith("/artist/7/playlists", { + expect(fetchChartmetric).toHaveBeenCalledWith("/artist/7/playlists", { limit: "10", platform: "spotify", }); @@ -72,7 +72,7 @@ describe("handleArtistResearch", () => { it("returns the upstream status as an error when proxy is non-200", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: 1 } as never); - vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 502, data: null } as never); + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 502, data: null } as never); const result = await handleArtistResearch({ artist: "X", @@ -86,7 +86,7 @@ describe("handleArtistResearch", () => { it("swallows credit-deduction failures and still returns data", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: 1 } as never); - vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(deductCredits).mockRejectedValue(new Error("DB down")); const result = await handleArtistResearch({ diff --git a/lib/research/__tests__/handleResearchProxy.test.ts b/lib/research/__tests__/handleResearch.test.ts similarity index 64% rename from lib/research/__tests__/handleResearchProxy.test.ts rename to lib/research/__tests__/handleResearch.test.ts index 114649e52..d0e9fc97d 100644 --- a/lib/research/__tests__/handleResearchProxy.test.ts +++ b/lib/research/__tests__/handleResearch.test.ts @@ -1,43 +1,43 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { handleResearchProxy } from "../handleResearchProxy"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { handleResearch } from "../handleResearch"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ deductCredits: vi.fn(), })); -describe("handleResearchProxy", () => { +describe("handleResearch", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns { data } on 200 and deducts the default 5 credits", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: [{ id: 1 }], } as never); vi.mocked(deductCredits).mockResolvedValue(undefined as never); - const result = await handleResearchProxy({ + const result = await handleResearch({ accountId: "acc_1", path: "/charts/spotify", query: { country_code: "US" }, }); - expect(proxyToChartmetric).toHaveBeenCalledWith("/charts/spotify", { country_code: "US" }); + expect(fetchChartmetric).toHaveBeenCalledWith("/charts/spotify", { country_code: "US" }); expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5 }); expect(result).toEqual({ data: [{ id: 1 }] }); }); it("returns { error, status } when proxy is non-200 and skips deduction", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 502, data: null } as never); + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 502, data: null } as never); - const result = await handleResearchProxy({ + const result = await handleResearch({ accountId: "acc_1", path: "/charts/spotify", }); @@ -47,10 +47,10 @@ describe("handleResearchProxy", () => { }); it("still returns { data } when credit deduction throws", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(deductCredits).mockRejectedValue(new Error("DB down")); - const result = await handleResearchProxy({ + const result = await handleResearch({ accountId: "acc_1", path: "/x", }); @@ -59,10 +59,10 @@ describe("handleResearchProxy", () => { }); it("respects the credits override", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: {} } as never); vi.mocked(deductCredits).mockResolvedValue(undefined as never); - await handleResearchProxy({ + await handleResearch({ accountId: "acc_1", path: "/x", credits: 12, diff --git a/lib/research/__tests__/resolveArtist.test.ts b/lib/research/__tests__/resolveArtist.test.ts index 8a953e278..4de805819 100644 --- a/lib/research/__tests__/resolveArtist.test.ts +++ b/lib/research/__tests__/resolveArtist.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { resolveArtist } from "../resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; -vi.mock("@/lib/research/proxyToChartmetric", () => ({ - proxyToChartmetric: vi.fn(), +vi.mock("@/lib/research/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); describe("resolveArtist", () => { @@ -16,7 +16,7 @@ describe("resolveArtist", () => { const result = await resolveArtist("3380"); expect(result).toEqual({ id: 3380 }); - expect(proxyToChartmetric).not.toHaveBeenCalled(); + expect(fetchChartmetric).not.toHaveBeenCalled(); }); it("returns error for UUID (not yet implemented)", async () => { @@ -27,7 +27,7 @@ describe("resolveArtist", () => { }); it("searches Chartmetric by name and returns top match", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: { artists: [{ id: 3380, name: "Drake" }] }, status: 200, }); @@ -35,7 +35,7 @@ describe("resolveArtist", () => { const result = await resolveArtist("Drake"); expect(result).toEqual({ id: 3380 }); - expect(proxyToChartmetric).toHaveBeenCalledWith("/search", { + expect(fetchChartmetric).toHaveBeenCalledWith("/search", { q: "Drake", type: "artists", limit: "1", @@ -43,7 +43,7 @@ describe("resolveArtist", () => { }); it("returns error when no artist found", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: { artists: [] }, status: 200, }); @@ -55,7 +55,7 @@ describe("resolveArtist", () => { }); it("returns error when search fails", async () => { - vi.mocked(proxyToChartmetric).mockResolvedValue({ + vi.mocked(fetchChartmetric).mockResolvedValue({ data: { error: "failed" }, status: 500, }); diff --git a/lib/research/proxyToChartmetric.ts b/lib/research/fetchChartmetric.ts similarity index 97% rename from lib/research/proxyToChartmetric.ts rename to lib/research/fetchChartmetric.ts index aee4f8b5a..38eedad54 100644 --- a/lib/research/proxyToChartmetric.ts +++ b/lib/research/fetchChartmetric.ts @@ -15,7 +15,7 @@ interface ProxyResult { * @param queryParams - Optional query parameters to append * @returns The response data (contents of `obj` if present, otherwise full response) */ -export async function proxyToChartmetric( +export async function fetchChartmetric( path: string, queryParams?: Record, ): Promise { diff --git a/lib/research/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts index 2672021b6..dcc786f8e 100644 --- a/lib/research/getResearchChartsHandler.ts +++ b/lib/research/getResearchChartsHandler.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { errorResponse } from "@/lib/networking/errorResponse"; import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearchProxy } from "@/lib/research/handleResearchProxy"; +import { handleResearch } from "@/lib/research/handleResearch"; import { validateGetResearchChartsRequest } from "@/lib/research/validateGetResearchChartsRequest"; /** @@ -18,7 +18,7 @@ export async function getResearchChartsHandler(request: NextRequest): Promise; @@ -9,7 +9,7 @@ export type HandleResearchProxyParams = { credits?: number; }; -export type HandleResearchProxyResult = { data: unknown } | { error: string; status: number }; +export type HandleResearchResult = { data: unknown } | { error: string; status: number }; /** * Proxies a non-artist-scoped research call to Chartmetric and deducts credits @@ -20,12 +20,10 @@ export type HandleResearchProxyResult = { data: unknown } | { error: string; sta * * @returns `{ data }` on success, `{ error, status }` on upstream failure. */ -export async function handleResearchProxy( - params: HandleResearchProxyParams, -): Promise { +export async function handleResearch(params: HandleResearchParams): Promise { const { accountId, path, query, credits = 5 } = params; - const result = await proxyToChartmetric(path, query); + const result = await fetchChartmetric(path, query); if (result.status !== 200) { return { error: `Request failed with status ${result.status}`, status: result.status }; } diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts index 86b76bbf5..f3557f843 100644 --- a/lib/research/resolveArtist.ts +++ b/lib/research/resolveArtist.ts @@ -1,4 +1,4 @@ -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -32,7 +32,7 @@ export async function resolveArtist( }; } - const result = await proxyToChartmetric("/search", { + const result = await fetchChartmetric("/search", { q: trimmed, type: "artists", limit: "1", diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index ce41526fe..006eb0e06 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,6 +1,6 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; interface GetIdsResponse { chartmetric_ids?: number[]; @@ -50,7 +50,7 @@ export async function resolveTrack( const isrc = spotifyTrack.external_ids?.isrc; if (isrc) { - const result = await proxyToChartmetric(`/track/isrc/${isrc}/get-ids`); + const result = await fetchChartmetric(`/track/isrc/${isrc}/get-ids`); if (result.status === 200) { const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; const cmId = ids?.chartmetric_ids?.[0]; @@ -59,7 +59,7 @@ export async function resolveTrack( } const spotifyId = spotifyTrack.id; - const result = await proxyToChartmetric(`/track/spotify/${spotifyId}/get-ids`); + const result = await fetchChartmetric(`/track/spotify/${spotifyId}/get-ids`); if (result.status === 200) { const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; const cmId = ids?.chartmetric_ids?.[0]; From 4c0b4e6e94b455c3e3985b148e29bef2d7ae2dcb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:31:22 -0500 Subject: [PATCH 18/28] refactor: move fetchChartmetric to lib/chartmetric Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/fetchChartmetric.test.ts | 2 +- lib/{research => chartmetric}/fetchChartmetric.ts | 0 lib/research/__tests__/getResearchChartsHandler.test.ts | 4 ++-- lib/research/__tests__/getResearchDiscoverHandler.test.ts | 4 ++-- lib/research/__tests__/getResearchLookupHandler.test.ts | 4 ++-- lib/research/__tests__/getResearchMetricsHandler.test.ts | 2 +- lib/research/__tests__/getResearchSearchHandler.test.ts | 4 ++-- lib/research/__tests__/getResearchSimilarHandler.test.ts | 4 ++-- lib/research/__tests__/getResearchTrackHandler.test.ts | 4 ++-- .../__tests__/getResearchTrackPlaylistsHandler.test.ts | 4 ++-- lib/research/__tests__/handleArtistResearch.test.ts | 4 ++-- lib/research/__tests__/handleResearch.test.ts | 4 ++-- lib/research/__tests__/resolveArtist.test.ts | 4 ++-- lib/research/getResearchLookupHandler.ts | 2 +- lib/research/getResearchPlaylistHandler.ts | 2 +- lib/research/getResearchSearchHandler.ts | 2 +- lib/research/getResearchTrackHandler.ts | 2 +- lib/research/getResearchTrackPlaylistsHandler.ts | 2 +- lib/research/handleArtistResearch.ts | 2 +- lib/research/handleResearch.ts | 2 +- lib/research/resolveArtist.ts | 2 +- lib/research/resolveTrack.ts | 2 +- 22 files changed, 31 insertions(+), 31 deletions(-) rename lib/{research => chartmetric}/__tests__/fetchChartmetric.test.ts (97%) rename lib/{research => chartmetric}/fetchChartmetric.ts (100%) diff --git a/lib/research/__tests__/fetchChartmetric.test.ts b/lib/chartmetric/__tests__/fetchChartmetric.test.ts similarity index 97% rename from lib/research/__tests__/fetchChartmetric.test.ts rename to lib/chartmetric/__tests__/fetchChartmetric.test.ts index 30142bc46..06bdb8571 100644 --- a/lib/research/__tests__/fetchChartmetric.test.ts +++ b/lib/chartmetric/__tests__/fetchChartmetric.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fetchChartmetric } from "../fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ getChartmetricToken: vi.fn().mockResolvedValue("mock-token"), diff --git a/lib/research/fetchChartmetric.ts b/lib/chartmetric/fetchChartmetric.ts similarity index 100% rename from lib/research/fetchChartmetric.ts rename to lib/chartmetric/fetchChartmetric.ts diff --git a/lib/research/__tests__/getResearchChartsHandler.test.ts b/lib/research/__tests__/getResearchChartsHandler.test.ts index 10e1f7229..dd1b5fb2f 100644 --- a/lib/research/__tests__/getResearchChartsHandler.test.ts +++ b/lib/research/__tests__/getResearchChartsHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; import { getResearchChartsHandler } from "../getResearchChartsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,7 +13,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/getResearchDiscoverHandler.test.ts b/lib/research/__tests__/getResearchDiscoverHandler.test.ts index b7c3cf34d..030d7bc16 100644 --- a/lib/research/__tests__/getResearchDiscoverHandler.test.ts +++ b/lib/research/__tests__/getResearchDiscoverHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchDiscoverHandler } from "../getResearchDiscoverHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,7 +13,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/getResearchLookupHandler.test.ts b/lib/research/__tests__/getResearchLookupHandler.test.ts index 15cb42d67..b208baad4 100644 --- a/lib/research/__tests__/getResearchLookupHandler.test.ts +++ b/lib/research/__tests__/getResearchLookupHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; import { getResearchLookupHandler } from "../getResearchLookupHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,7 +13,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/getResearchMetricsHandler.test.ts b/lib/research/__tests__/getResearchMetricsHandler.test.ts index f8efdf5cb..80d0127bd 100644 --- a/lib/research/__tests__/getResearchMetricsHandler.test.ts +++ b/lib/research/__tests__/getResearchMetricsHandler.test.ts @@ -16,7 +16,7 @@ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts index 66edd682f..ae15fb48a 100644 --- a/lib/research/__tests__/getResearchSearchHandler.test.ts +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchSearchHandler } from "../getResearchSearchHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,7 +13,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/getResearchSimilarHandler.test.ts b/lib/research/__tests__/getResearchSimilarHandler.test.ts index 509797a50..25de06558 100644 --- a/lib/research/__tests__/getResearchSimilarHandler.test.ts +++ b/lib/research/__tests__/getResearchSimilarHandler.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from "next/server"; import { getResearchSimilarHandler } from "../getResearchSimilarHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -18,7 +18,7 @@ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts index a1914f9af..0ec045609 100644 --- a/lib/research/__tests__/getResearchTrackHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackHandler } from "../getResearchTrackHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -13,7 +13,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 6d0b7a4ae..41c7a7175 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { resolveTrack } from "@/lib/research/resolveTrack"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -14,7 +14,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/__tests__/handleArtistResearch.test.ts b/lib/research/__tests__/handleArtistResearch.test.ts index e8f01fca9..60e91482f 100644 --- a/lib/research/__tests__/handleArtistResearch.test.ts +++ b/lib/research/__tests__/handleArtistResearch.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleArtistResearch } from "../handleArtistResearch"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ diff --git a/lib/research/__tests__/handleResearch.test.ts b/lib/research/__tests__/handleResearch.test.ts index d0e9fc97d..78eec0937 100644 --- a/lib/research/__tests__/handleResearch.test.ts +++ b/lib/research/__tests__/handleResearch.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleResearch } from "../handleResearch"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); vi.mock("@/lib/credits/deductCredits", () => ({ diff --git a/lib/research/__tests__/resolveArtist.test.ts b/lib/research/__tests__/resolveArtist.test.ts index 4de805819..a08e20daa 100644 --- a/lib/research/__tests__/resolveArtist.test.ts +++ b/lib/research/__tests__/resolveArtist.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { resolveArtist } from "../resolveArtist"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; -vi.mock("@/lib/research/fetchChartmetric", () => ({ +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts index 9ee083c7c..bafd74da1 100644 --- a/lib/research/getResearchLookupHandler.ts +++ b/lib/research/getResearchLookupHandler.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts index 252d05bef..879dec66a 100644 --- a/lib/research/getResearchPlaylistHandler.ts +++ b/lib/research/getResearchPlaylistHandler.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; /** * Playlist detail handler — looks up a playlist by platform and ID, falling back to name search. diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts index 8af5227fe..9f0c769b7 100644 --- a/lib/research/getResearchSearchHandler.ts +++ b/lib/research/getResearchSearchHandler.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; /** * Search handler — looks up artists/tracks/albums by name via Chartmetric. diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts index b2e96423e..ee4caba19 100644 --- a/lib/research/getResearchTrackHandler.ts +++ b/lib/research/getResearchTrackHandler.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; /** * Track handler — searches Chartmetric for a track by name, then fetches full details for the top match. diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts index 19e005319..7eaddebbb 100644 --- a/lib/research/getResearchTrackPlaylistsHandler.ts +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { resolveTrack } from "@/lib/research/resolveTrack"; const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts index 8970f4e66..4b0d53eef 100644 --- a/lib/research/handleArtistResearch.ts +++ b/lib/research/handleArtistResearch.ts @@ -1,5 +1,5 @@ import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; export type HandleArtistResearchParams = { diff --git a/lib/research/handleResearch.ts b/lib/research/handleResearch.ts index 31766770b..b78e9f22d 100644 --- a/lib/research/handleResearch.ts +++ b/lib/research/handleResearch.ts @@ -1,4 +1,4 @@ -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { deductCredits } from "@/lib/credits/deductCredits"; export type HandleResearchParams = { diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts index f3557f843..17b57a69c 100644 --- a/lib/research/resolveArtist.ts +++ b/lib/research/resolveArtist.ts @@ -1,4 +1,4 @@ -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 006eb0e06..4c721ee3c 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,6 +1,6 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; -import { fetchChartmetric } from "@/lib/research/fetchChartmetric"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; interface GetIdsResponse { chartmetric_ids?: number[]; From 147dff687cbba70a5d965fff13a7b47633609aa0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:33:34 -0500 Subject: [PATCH 19/28] refactor(dry): share CHARTMETRIC_BASE between token exchange and fetchChartmetric Extracts the base URL into lib/chartmetric/chartmetricBase.ts so both getChartmetricToken and fetchChartmetric reuse the same const. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/chartmetric/chartmetricBase.ts | 2 ++ lib/chartmetric/fetchChartmetric.ts | 3 +-- lib/chartmetric/getChartmetricToken.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 lib/chartmetric/chartmetricBase.ts diff --git a/lib/chartmetric/chartmetricBase.ts b/lib/chartmetric/chartmetricBase.ts new file mode 100644 index 000000000..b86e92986 --- /dev/null +++ b/lib/chartmetric/chartmetricBase.ts @@ -0,0 +1,2 @@ +/** Base URL for the Chartmetric REST API. Shared by token exchange and proxy fetches. */ +export const CHARTMETRIC_BASE = "https://api.chartmetric.com/api"; diff --git a/lib/chartmetric/fetchChartmetric.ts b/lib/chartmetric/fetchChartmetric.ts index 38eedad54..ec04abcd1 100644 --- a/lib/chartmetric/fetchChartmetric.ts +++ b/lib/chartmetric/fetchChartmetric.ts @@ -1,6 +1,5 @@ import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; - -const CHARTMETRIC_BASE = "https://api.chartmetric.com/api"; +import { CHARTMETRIC_BASE } from "@/lib/chartmetric/chartmetricBase"; interface ProxyResult { data: unknown; diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts index fcfdf52af..41dc958a0 100644 --- a/lib/chartmetric/getChartmetricToken.ts +++ b/lib/chartmetric/getChartmetricToken.ts @@ -1,4 +1,5 @@ import { chartmetricTokenCache } from "./chartmetricTokenCache"; +import { CHARTMETRIC_BASE } from "./chartmetricBase"; /** * Exchanges the Chartmetric refresh token for a short-lived access token. @@ -18,7 +19,7 @@ export async function getChartmetricToken(): Promise { throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); } - const response = await fetch("https://api.chartmetric.com/api/token", { + const response = await fetch(`${CHARTMETRIC_BASE}/token`, { method: "POST", headers: { "Content-Type": "application/json", From f3dca50e195918d8a69a38d33f63164507acbb78 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:49:36 -0500 Subject: [PATCH 20/28] refactor: port 5 non-artist research GET handlers to validator+handleResearch pattern - getResearchLookupHandler - getResearchSearchHandler - getResearchTrackHandler - getResearchTrackPlaylistsHandler - getResearchPlaylistHandler Each now has a dedicated validateGetResearchRequest.ts (auth + param validation) and composes handleResearch + errorResponse / successResponse inside try/catch. Removes hand-rolled auth + direct deductCredits calls from the handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../getResearchLookupHandler.test.ts | 55 +++--- .../getResearchSearchHandler.test.ts | 61 +++---- .../__tests__/getResearchTrackHandler.test.ts | 86 ++++++---- .../getResearchTrackPlaylistsHandler.test.ts | 159 ++++++------------ .../validateGetResearchLookupRequest.test.ts | 65 +++++++ ...validateGetResearchPlaylistRequest.test.ts | 71 ++++++++ .../validateGetResearchSearchRequest.test.ts | 58 +++++++ ...teGetResearchTrackPlaylistsRequest.test.ts | 101 +++++++++++ .../validateGetResearchTrackRequest.test.ts | 49 ++++++ lib/research/getResearchLookupHandler.ts | 81 +++------ lib/research/getResearchPlaylistHandler.ts | 116 +++++-------- lib/research/getResearchSearchHandler.ts | 65 +++---- lib/research/getResearchTrackHandler.ts | 107 +++++------- .../getResearchTrackPlaylistsHandler.ts | 148 ++++------------ .../validateGetResearchLookupRequest.ts | 32 ++++ .../validateGetResearchPlaylistRequest.ts | 38 +++++ .../validateGetResearchSearchRequest.ts | 34 ++++ ...alidateGetResearchTrackPlaylistsRequest.ts | 100 +++++++++++ .../validateGetResearchTrackRequest.ts | 26 +++ 19 files changed, 893 insertions(+), 559 deletions(-) create mode 100644 lib/research/__tests__/validateGetResearchLookupRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchSearchRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchTrackPlaylistsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchTrackRequest.test.ts create mode 100644 lib/research/validateGetResearchLookupRequest.ts create mode 100644 lib/research/validateGetResearchPlaylistRequest.ts create mode 100644 lib/research/validateGetResearchSearchRequest.ts create mode 100644 lib/research/validateGetResearchTrackPlaylistsRequest.ts create mode 100644 lib/research/validateGetResearchTrackRequest.ts diff --git a/lib/research/__tests__/getResearchLookupHandler.test.ts b/lib/research/__tests__/getResearchLookupHandler.test.ts index b208baad4..99b898e39 100644 --- a/lib/research/__tests__/getResearchLookupHandler.test.ts +++ b/lib/research/__tests__/getResearchLookupHandler.test.ts @@ -1,48 +1,51 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getResearchLookupHandler } from "../getResearchLookupHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { validateGetResearchLookupRequest } from "../validateGetResearchLookupRequest"; +import { handleResearch } from "../handleResearch"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), +vi.mock("../validateGetResearchLookupRequest", () => ({ + validateGetResearchLookupRequest: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), -})); - -vi.mock("@/lib/credits/deductCredits", () => ({ - deductCredits: vi.fn(), +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), })); describe("getResearchLookupHandler", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(validateAuthContext).mockResolvedValue({ + vi.mocked(validateGetResearchLookupRequest).mockResolvedValue({ accountId: "test-id", - orgId: null, - authToken: "token", + spotifyId: "3TVXtAsR1Inumwj472S9r4", }); }); - it("returns 400 when url is missing", async () => { + it("passes through validator error response", async () => { + const err = NextResponse.json({ error: "bad" }, { status: 400 }); + vi.mocked(validateGetResearchLookupRequest).mockResolvedValue(err); const req = new NextRequest("http://localhost/api/research/lookup"); const res = await getResearchLookupHandler(req); - expect(res.status).toBe(400); + expect(res).toBe(err); }); - it("returns 400 when url is not a Spotify artist URL", async () => { - const req = new NextRequest("http://localhost/api/research/lookup?url=https://google.com"); + it("returns 'Lookup failed' on upstream error", async () => { + vi.mocked(handleResearch).mockResolvedValue({ + error: "Request failed with status 502", + status: 502, + }); + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + ); const res = await getResearchLookupHandler(req); - expect(res.status).toBe(400); + expect(res.status).toBe(502); const body = await res.json(); - expect(body.error).toContain("Spotify artist URL"); + expect(body.error).toBe("Lookup failed"); }); it("wraps array responses in a data field instead of spreading indices", async () => { @@ -51,10 +54,7 @@ describe("getResearchLookupHandler", () => { { id: 2, platform: "apple_music" }, ]; - vi.mocked(fetchChartmetric).mockResolvedValue({ - data: arrayData, - status: 200, - }); + vi.mocked(handleResearch).mockResolvedValue({ data: arrayData }); const req = new NextRequest( "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", @@ -64,18 +64,13 @@ describe("getResearchLookupHandler", () => { const body = await res.json(); expect(body.status).toBe("success"); - // Should wrap in data field, NOT spread as {"0":...,"1":...} expect(body.data).toEqual(arrayData); expect(body).not.toHaveProperty("0"); }); it("spreads object responses normally", async () => { const objectData = { id: 3380, spotify_id: "3TVXtAsR1Inumwj472S9r4" }; - - vi.mocked(fetchChartmetric).mockResolvedValue({ - data: objectData, - status: 200, - }); + vi.mocked(handleResearch).mockResolvedValue({ data: objectData }); const req = new NextRequest( "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts index ae15fb48a..1980c17be 100644 --- a/lib/research/__tests__/getResearchSearchHandler.test.ts +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -2,66 +2,57 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getResearchSearchHandler } from "../getResearchSearchHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { validateGetResearchSearchRequest } from "../validateGetResearchSearchRequest"; +import { handleResearch } from "../handleResearch"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), +vi.mock("../validateGetResearchSearchRequest", () => ({ + validateGetResearchSearchRequest: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), -})); - -vi.mock("@/lib/credits/deductCredits", () => ({ - deductCredits: vi.fn(), +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), })); describe("getResearchSearchHandler", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(validateGetResearchSearchRequest).mockResolvedValue({ + accountId: "test-id", + q: "Drake", + type: "artists", + limit: "10", + }); }); - it("returns 401 when auth fails", async () => { - const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); - - const req = new NextRequest("http://localhost/api/research?q=Drake"); + it("passes through validator error response", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateGetResearchSearchRequest).mockResolvedValue(err); + const req = new NextRequest("http://localhost/api/research/search"); const res = await getResearchSearchHandler(req); - expect(res.status).toBe(401); + expect(res).toBe(err); }); - it("returns 400 when q param is missing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", + it("returns 'Search failed' on upstream error", async () => { + vi.mocked(handleResearch).mockResolvedValue({ + error: "Request failed with status 502", + status: 502, }); - - const req = new NextRequest("http://localhost/api/research"); + const req = new NextRequest("http://localhost/api/research/search?q=Drake"); const res = await getResearchSearchHandler(req); - expect(res.status).toBe(400); + expect(res.status).toBe(502); const body = await res.json(); - expect(body.error).toContain("q parameter is required"); + expect(body.error).toBe("Search failed"); }); it("returns 200 with results on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); - - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(handleResearch).mockResolvedValue({ data: { artists: [{ name: "Drake", id: 3380 }] }, - status: 200, }); - - const req = new NextRequest("http://localhost/api/research?q=Drake"); + const req = new NextRequest("http://localhost/api/research/search?q=Drake"); const res = await getResearchSearchHandler(req); expect(res.status).toBe(200); const body = await res.json(); diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts index 0ec045609..31592f472 100644 --- a/lib/research/__tests__/getResearchTrackHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -2,69 +2,85 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackHandler } from "../getResearchTrackHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateGetResearchTrackRequest } from "../validateGetResearchTrackRequest"; +import { handleResearch } from "../handleResearch"; import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), +vi.mock("../validateGetResearchTrackRequest", () => ({ + validateGetResearchTrackRequest: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), })); -vi.mock("@/lib/credits/deductCredits", () => ({ - deductCredits: vi.fn(), +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), })); describe("getResearchTrackHandler", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ + accountId: "test-id", + q: "Hotline Bling", + }); }); - it("returns 401 when auth fails", async () => { - const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + it("passes through validator error response", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateGetResearchTrackRequest).mockResolvedValue(err); + const req = new NextRequest("http://localhost/api/research/track"); + const res = await getResearchTrackHandler(req); + expect(res).toBe(err); + }); - const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); + it("returns 'Track search failed' when search errors", async () => { + vi.mocked(fetchChartmetric).mockResolvedValue({ data: null, status: 502 }); + const req = new NextRequest("http://localhost/api/research/track?q=foo"); + const res = await getResearchTrackHandler(req); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error).toBe("Track search failed"); + }); + + it("returns 404 when no track matches", async () => { + vi.mocked(fetchChartmetric).mockResolvedValue({ data: { tracks: [] }, status: 200 }); + const req = new NextRequest("http://localhost/api/research/track?q=nothing"); const res = await getResearchTrackHandler(req); - expect(res.status).toBe(401); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toContain("No track found"); }); - it("returns 400 when q param is missing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", + it("returns 'Failed to fetch track details' on detail error", async () => { + vi.mocked(fetchChartmetric).mockResolvedValue({ + data: { tracks: [{ id: 12345 }] }, + status: 200, }); - - const req = new NextRequest("http://localhost/api/research/track"); + vi.mocked(handleResearch).mockResolvedValue({ + error: "Request failed with status 503", + status: 503, + }); + const req = new NextRequest("http://localhost/api/research/track?q=foo"); const res = await getResearchTrackHandler(req); - expect(res.status).toBe(400); + expect(res.status).toBe(503); const body = await res.json(); - expect(body.error).toContain("q parameter is required"); + expect(body.error).toBe("Failed to fetch track details"); }); it("returns 200 with track data on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", + vi.mocked(fetchChartmetric).mockResolvedValue({ + data: { tracks: [{ id: 12345 }] }, + status: 200, + }); + vi.mocked(handleResearch).mockResolvedValue({ + data: { name: "Hotline Bling", artist: "Drake", id: 12345 }, }); - - vi.mocked(fetchChartmetric) - .mockResolvedValueOnce({ - data: { tracks: [{ id: 12345 }] }, - status: 200, - }) - .mockResolvedValueOnce({ - data: { name: "Hotline Bling", artist: "Drake", id: 12345 }, - status: 200, - }); const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); const res = await getResearchTrackHandler(req); diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 41c7a7175..244d92fb6 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -2,98 +2,53 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; -import { resolveTrack } from "@/lib/research/resolveTrack"; +import { validateGetResearchTrackPlaylistsRequest } from "../validateGetResearchTrackPlaylistsRequest"; +import { handleResearch } from "../handleResearch"; +import { resolveTrack } from "../resolveTrack"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), +vi.mock("../validateGetResearchTrackPlaylistsRequest", () => ({ + validateGetResearchTrackPlaylistsRequest: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), })); -vi.mock("@/lib/research/resolveTrack", () => ({ +vi.mock("../resolveTrack", () => ({ resolveTrack: vi.fn(), })); -vi.mock("@/lib/credits/deductCredits", () => ({ - deductCredits: vi.fn(), -})); +const baseValidated = { + accountId: "test-id", + id: "18220712" as string | null, + q: null as string | null, + artist: undefined as string | undefined, + platform: "spotify", + status: "current", + filters: { editorial: "true" }, + pagination: {}, +}; describe("getResearchTrackPlaylistsHandler", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(validateGetResearchTrackPlaylistsRequest).mockResolvedValue({ ...baseValidated }); }); - it("returns 401 when auth fails", async () => { - const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); - - const req = new NextRequest("http://localhost/api/research/track/playlists?id=18220712"); - const res = await getResearchTrackPlaylistsHandler(req); - expect(res.status).toBe(401); - }); - - it("returns 400 when both id and q are missing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); - + it("passes through validator error response", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateGetResearchTrackPlaylistsRequest).mockResolvedValue(err); const req = new NextRequest("http://localhost/api/research/track/playlists"); const res = await getResearchTrackPlaylistsHandler(req); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain("id or q parameter is required"); - }); - - it("returns 400 for invalid platform", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); - - const req = new NextRequest( - "http://localhost/api/research/track/playlists?id=123&platform=invalid", - ); - const res = await getResearchTrackPlaylistsHandler(req); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain("Invalid platform"); - }); - - it("returns 400 for invalid status", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); - - const req = new NextRequest( - "http://localhost/api/research/track/playlists?id=123&status=invalid", - ); - const res = await getResearchTrackPlaylistsHandler(req); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain("status must be"); + expect(res).toBe(err); }); - it("returns 200 with playlists when given a track id", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); - - vi.mocked(fetchChartmetric).mockResolvedValue({ + it("returns 200 with placements when given a track id", async () => { + vi.mocked(handleResearch).mockResolvedValue({ data: [ { playlist: { @@ -104,12 +59,8 @@ describe("getResearchTrackPlaylistsHandler", () => { track: { name: "God's Plan", cm_track: 18220712 }, }, ], - status: 200, }); - - const req = new NextRequest( - "http://localhost/api/research/track/playlists?id=18220712&editorial=true", - ); + const req = new NextRequest("http://localhost/api/research/track/playlists?id=18220712"); const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(200); const body = await res.json(); @@ -119,22 +70,15 @@ describe("getResearchTrackPlaylistsHandler", () => { }); it("resolves track by name when q is provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", + vi.mocked(validateGetResearchTrackPlaylistsRequest).mockResolvedValue({ + ...baseValidated, + id: null, + q: "God's Plan", + artist: "Drake", }); - vi.mocked(resolveTrack).mockResolvedValue({ id: "18220712" }); - - vi.mocked(fetchChartmetric).mockResolvedValue({ - data: [ - { - playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, - track: { name: "God's Plan" }, - }, - ], - status: 200, + vi.mocked(handleResearch).mockResolvedValue({ + data: [{ playlist: { name: "Today's Top Hits" }, track: { name: "God's Plan" } }], }); const req = new NextRequest( @@ -143,43 +87,40 @@ describe("getResearchTrackPlaylistsHandler", () => { const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(200); const body = await res.json(); - expect(body.status).toBe("success"); expect(body.placements).toHaveLength(1); expect(vi.mocked(resolveTrack)).toHaveBeenCalledWith("God's Plan", "Drake"); }); it("returns 404 when track name search finds nothing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", + vi.mocked(validateGetResearchTrackPlaylistsRequest).mockResolvedValue({ + ...baseValidated, + id: null, + q: "nonexistent", }); - vi.mocked(resolveTrack).mockResolvedValue({ - error: 'No track found matching "nonexistent song"', + error: 'No track found matching "nonexistent"', }); - - const req = new NextRequest("http://localhost/api/research/track/playlists?q=nonexistent+song"); + const req = new NextRequest("http://localhost/api/research/track/playlists?q=nonexistent"); const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(404); }); it("returns empty placements when Chartmetric returns non-array", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); - - vi.mocked(fetchChartmetric).mockResolvedValue({ - data: null, - status: 200, - }); - + vi.mocked(handleResearch).mockResolvedValue({ data: null }); const req = new NextRequest("http://localhost/api/research/track/playlists?id=123"); const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(200); const body = await res.json(); expect(body.placements).toEqual([]); }); + + it("propagates upstream error status", async () => { + vi.mocked(handleResearch).mockResolvedValue({ + error: "Request failed with status 502", + status: 502, + }); + const req = new NextRequest("http://localhost/api/research/track/playlists?id=123"); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(502); + }); }); diff --git a/lib/research/__tests__/validateGetResearchLookupRequest.test.ts b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts new file mode 100644 index 000000000..18521d822 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchLookupRequest } from "../validateGetResearchLookupRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchLookupRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/abc", + ); + const res = await validateGetResearchLookupRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when url is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/lookup"); + const res = await validateGetResearchLookupRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("url parameter is required"); + }); + + it("returns 400 when url is not a Spotify artist URL", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/lookup?url=https://google.com"); + const res = await validateGetResearchLookupRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("Spotify artist URL"); + }); + + it("extracts spotifyId from a valid URL", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + ); + const res = await validateGetResearchLookupRequest(req); + expect(res).toEqual({ accountId: "acc_1", spotifyId: "3TVXtAsR1Inumwj472S9r4" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts b/lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts new file mode 100644 index 000000000..2e3283647 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchPlaylistRequest } from "../validateGetResearchPlaylistRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchPlaylistRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + const req = new NextRequest("http://localhost/api/research/playlist?platform=spotify&id=1"); + const res = await validateGetResearchPlaylistRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when platform is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/playlist?id=1"); + const res = await validateGetResearchPlaylistRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("platform and id"); + }); + + it("returns 400 when id is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/playlist?platform=spotify"); + const res = await validateGetResearchPlaylistRequest(req); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns 400 for invalid platform", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/playlist?platform=bogus&id=1"); + const res = await validateGetResearchPlaylistRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("Invalid platform"); + }); + + it("returns validated request on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/playlist?platform=spotify&id=37i9dQZF1DXcBWIGoYBM5M", + ); + const res = await validateGetResearchPlaylistRequest(req); + expect(res).toEqual({ + accountId: "acc_1", + platform: "spotify", + id: "37i9dQZF1DXcBWIGoYBM5M", + }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchSearchRequest.test.ts b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts new file mode 100644 index 000000000..2dea11ec0 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchSearchRequest } from "../validateGetResearchSearchRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchSearchRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + const req = new NextRequest("http://localhost/api/research/search?q=Drake"); + const res = await validateGetResearchSearchRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when q is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/search"); + const res = await validateGetResearchSearchRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("q parameter is required"); + }); + + it("fills defaults for type and limit", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/search?q=Drake"); + const res = await validateGetResearchSearchRequest(req); + expect(res).toEqual({ accountId: "acc_1", q: "Drake", type: "artists", limit: "10" }); + }); + + it("preserves explicit type and limit", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/search?q=Drake&type=tracks&limit=25", + ); + const res = await validateGetResearchSearchRequest(req); + expect(res).toMatchObject({ type: "tracks", limit: "25" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchTrackPlaylistsRequest.test.ts b/lib/research/__tests__/validateGetResearchTrackPlaylistsRequest.test.ts new file mode 100644 index 000000000..64685aebe --- /dev/null +++ b/lib/research/__tests__/validateGetResearchTrackPlaylistsRequest.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchTrackPlaylistsRequest } from "../validateGetResearchTrackPlaylistsRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchTrackPlaylistsRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + const req = new NextRequest("http://localhost/api/research/track/playlists?id=1"); + const res = await validateGetResearchTrackPlaylistsRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when both id and q are missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track/playlists"); + const res = await validateGetResearchTrackPlaylistsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("id or q parameter is required"); + }); + + it("returns 400 for invalid platform", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=1&platform=bogus", + ); + const res = await validateGetResearchTrackPlaylistsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("Invalid platform"); + }); + + it("returns 400 for invalid status", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track/playlists?id=1&status=bogus"); + const res = await validateGetResearchTrackPlaylistsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("status must be"); + }); + + it("applies default filters when none supplied", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track/playlists?id=1"); + const res = await validateGetResearchTrackPlaylistsRequest(req); + expect(res).not.toBeInstanceOf(NextResponse); + const v = res as Exclude; + expect(v.filters).toEqual({ + editorial: "true", + indie: "true", + majorCurator: "true", + popularIndie: "true", + }); + expect(v.platform).toBe("spotify"); + expect(v.status).toBe("current"); + }); + + it("preserves user-supplied filters + pagination", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=1&editorial=false&chart=true&limit=20&offset=10&sort=date", + ); + const res = await validateGetResearchTrackPlaylistsRequest(req); + const v = res as Exclude; + expect(v.filters).toEqual({ editorial: "false", chart: "true" }); + expect(v.pagination).toEqual({ limit: "20", offset: "10", sortColumn: "date" }); + }); + + it("accepts q + artist without id", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/track/playlists?q=God%27s+Plan&artist=Drake", + ); + const res = await validateGetResearchTrackPlaylistsRequest(req); + const v = res as Exclude; + expect(v.q).toBe("God's Plan"); + expect(v.artist).toBe("Drake"); + expect(v.id).toBeNull(); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchTrackRequest.test.ts b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts new file mode 100644 index 000000000..081d3230a --- /dev/null +++ b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchTrackRequest } from "../validateGetResearchTrackRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchTrackRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + const req = new NextRequest("http://localhost/api/research/track?q=foo"); + const res = await validateGetResearchTrackRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when q is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track"); + const res = await validateGetResearchTrackRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("q parameter is required"); + }); + + it("returns the validated request", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); + const res = await validateGetResearchTrackRequest(req); + expect(res).toEqual({ accountId: "acc_1", q: "Hotline Bling" }); + }); +}); diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts index bafd74da1..2d6c49178 100644 --- a/lib/research/getResearchLookupHandler.ts +++ b/lib/research/getResearchLookupHandler.ts @@ -1,70 +1,37 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; - -const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearch } from "@/lib/research/handleResearch"; +import { validateGetResearchLookupRequest } from "@/lib/research/validateGetResearchLookupRequest"; /** * GET /api/research/lookup * - * Resolves a Spotify artist URL to Chartmetric IDs. Extracts the Spotify artist ID - * from the given URL and calls Chartmetric's get-ids endpoint to retrieve all - * cross-platform identifiers. + * Resolves a Spotify artist URL to Chartmetric IDs via the get-ids endpoint. * * @param request - Requires `url` query param containing a Spotify artist URL * @returns The JSON response. */ export async function getResearchLookupHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - const { searchParams } = new URL(request.url); - const url = searchParams.get("url"); - - if (!url) { - return NextResponse.json( - { status: "error", error: "url parameter is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const match = url.match(SPOTIFY_ARTIST_REGEX); - if (!match) { - return NextResponse.json( - { status: "error", error: "url must be a valid Spotify artist URL" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const spotifyId = match[1]; - - const result = await fetchChartmetric(`/artist/spotify/${spotifyId}/get-ids`); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Lookup failed" }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — log but don't block + const validated = await validateGetResearchLookupRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + accountId: validated.accountId, + path: `/artist/spotify/${validated.spotifyId}/get-ids`, + }); + + if ("error" in result) return errorResponse("Lookup failed", result.status); + + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchLookupHandler:", error); + return errorResponse("Internal error", 500); } - - const responseData = result.data; - - return NextResponse.json( - { - status: "success", - ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) - ? responseData - : { data: responseData }), - }, - { status: 200, headers: getCorsHeaders() }, - ); } diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts index 879dec66a..7e83c3e57 100644 --- a/lib/research/getResearchPlaylistHandler.ts +++ b/lib/research/getResearchPlaylistHandler.ts @@ -1,91 +1,67 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearch } from "@/lib/research/handleResearch"; +import { validateGetResearchPlaylistRequest } from "@/lib/research/validateGetResearchPlaylistRequest"; /** - * Playlist detail handler — looks up a playlist by platform and ID, falling back to name search. + * GET /api/research/playlist + * + * Looks up a playlist by platform + ID, falling back to a name search when the + * `id` is non-numeric. * * @param request - query params: platform, id * @returns JSON playlist details or error */ export async function getResearchPlaylistHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform"); - const id = searchParams.get("id"); - - if (!platform || !id) { - return NextResponse.json( - { status: "error", error: "platform and id parameters are required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + try { + const validated = await validateGetResearchPlaylistRequest(request); + if (validated instanceof NextResponse) return validated; - const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; - if (!VALID_PLATFORMS.includes(platform)) { - return NextResponse.json( - { status: "error", error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}` }, - { status: 400, headers: getCorsHeaders() }, - ); - } + const { accountId, platform, id } = validated; + let playlistId = id; - let playlistId = id; + if (!/^\d+$/.test(id)) { + const searchResult = await fetchChartmetric("/search", { + q: id, + type: "playlists", + limit: "1", + }); - if (!/^\d+$/.test(id)) { - const searchResult = await fetchChartmetric("/search", { - q: id, - type: "playlists", - limit: "1", - }); + if (searchResult.status !== 200) { + return errorResponse( + `Search failed with status ${searchResult.status}`, + searchResult.status, + ); + } - if (searchResult.status !== 200) { - return NextResponse.json( - { status: "error", error: `Search failed with status ${searchResult.status}` }, - { status: searchResult.status, headers: getCorsHeaders() }, - ); - } + const playlists = ( + searchResult.data as { playlists?: { [key: string]: Array<{ id: number }> } } + )?.playlists?.[platform]; - const playlists = ( - searchResult.data as { playlists?: { [key: string]: Array<{ id: number }> } } - )?.playlists?.[platform]; + if (!playlists || playlists.length === 0) { + return errorResponse(`No playlist found matching "${id}" on ${platform}`, 404); + } - if (!playlists || playlists.length === 0) { - return NextResponse.json( - { status: "error", error: `No playlist found matching "${id}" on ${platform}` }, - { status: 404, headers: getCorsHeaders() }, - ); + playlistId = String(playlists[0].id); } - playlistId = String(playlists[0].id); - } + const result = await handleResearch({ + accountId, + path: `/playlist/${platform}/${playlistId}`, + }); - const result = await fetchChartmetric(`/playlist/${platform}/${playlistId}`); + if ("error" in result) return errorResponse("Playlist lookup failed", result.status); - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Playlist lookup failed" }, - { status: result.status, headers: getCorsHeaders() }, - ); + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchPlaylistHandler:", error); + return errorResponse("Internal error", 500); } - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — log but don't block - } - - return NextResponse.json( - { - status: "success", - ...(typeof result.data === "object" && result.data !== null - ? result.data - : { data: result.data }), - }, - { status: 200, headers: getCorsHeaders() }, - ); } diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts index 9f0c769b7..4f9cc7804 100644 --- a/lib/research/getResearchSearchHandler.ts +++ b/lib/research/getResearchSearchHandler.ts @@ -1,52 +1,35 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearch } from "@/lib/research/handleResearch"; +import { validateGetResearchSearchRequest } from "@/lib/research/validateGetResearchSearchRequest"; /** - * Search handler — looks up artists/tracks/albums by name via Chartmetric. + * GET /api/research/search + * + * Searches Chartmetric for artists, tracks, or albums by name. * * @param request - must include `q` query param * @returns JSON search results or error */ export async function getResearchSearchHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - const { searchParams } = new URL(request.url); - const q = searchParams.get("q"); - const type = searchParams.get("type") || "artists"; - const limit = searchParams.get("limit") || "10"; - - if (!q) { - return NextResponse.json( - { status: "error", error: "q parameter is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const result = await fetchChartmetric("/search", { q, type, limit }); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Search failed" }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — log but don't block + const validated = await validateGetResearchSearchRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + accountId: validated.accountId, + path: "/search", + query: { q: validated.q, type: validated.type, limit: validated.limit }, + }); + + if ("error" in result) return errorResponse("Search failed", result.status); + + const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; + const results = data?.artists || data?.tracks || data?.albums || []; + return successResponse({ results }); + } catch (error) { + console.error("[ERROR] getResearchSearchHandler:", error); + return errorResponse("Internal error", 500); } - - const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; - const results = data?.artists || data?.tracks || data?.albums || []; - - return NextResponse.json( - { status: "success", results }, - { status: 200, headers: getCorsHeaders() }, - ); } diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts index ee4caba19..886aaa468 100644 --- a/lib/research/getResearchTrackHandler.ts +++ b/lib/research/getResearchTrackHandler.ts @@ -1,76 +1,55 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearch } from "@/lib/research/handleResearch"; +import { validateGetResearchTrackRequest } from "@/lib/research/validateGetResearchTrackRequest"; /** - * Track handler — searches Chartmetric for a track by name, then fetches full details for the top match. + * GET /api/research/track + * + * Searches Chartmetric for a track by name, then fetches full details for the + * top match. * * @param request - must include `q` query param * @returns JSON track details or error */ export async function getResearchTrackHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - const { searchParams } = new URL(request.url); - const q = searchParams.get("q"); - - if (!q) { - return NextResponse.json( - { status: "error", error: "q parameter is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const searchResult = await fetchChartmetric("/search", { - q, - type: "tracks", - limit: "1", - }); - - if (searchResult.status !== 200) { - return NextResponse.json( - { status: "error", error: "Track search failed" }, - { status: searchResult.status, headers: getCorsHeaders() }, - ); - } - - const searchData = searchResult.data as { tracks?: Array<{ id: number }> }; - const tracks = searchData?.tracks; - - if (!tracks || tracks.length === 0) { - return NextResponse.json( - { status: "error", error: `No track found matching "${q}"` }, - { status: 404, headers: getCorsHeaders() }, - ); - } - - const trackId = tracks[0].id; - const detailResult = await fetchChartmetric(`/track/${trackId}`); - - if (detailResult.status !== 200) { - return NextResponse.json( - { status: "error", error: "Failed to fetch track details" }, - { status: detailResult.status, headers: getCorsHeaders() }, - ); - } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — log but don't block + const validated = await validateGetResearchTrackRequest(request); + if (validated instanceof NextResponse) return validated; + + const searchResult = await fetchChartmetric("/search", { + q: validated.q, + type: "tracks", + limit: "1", + }); + + if (searchResult.status !== 200) { + return errorResponse("Track search failed", searchResult.status); + } + + const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; + if (!tracks || tracks.length === 0) { + return errorResponse(`No track found matching "${validated.q}"`, 404); + } + + const trackId = tracks[0].id; + const result = await handleResearch({ + accountId: validated.accountId, + path: `/track/${trackId}`, + }); + + if ("error" in result) return errorResponse("Failed to fetch track details", result.status); + + const data = result.data; + const body = + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : { data }; + return successResponse(body); + } catch (error) { + console.error("[ERROR] getResearchTrackHandler:", error); + return errorResponse("Internal error", 500); } - - return NextResponse.json( - { - status: "success", - ...(typeof detailResult.data === "object" && detailResult.data !== null - ? detailResult.data - : { data: detailResult.data }), - }, - { status: 200, headers: getCorsHeaders() }, - ); } diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts index 7eaddebbb..5a1d25465 100644 --- a/lib/research/getResearchTrackPlaylistsHandler.ts +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -1,134 +1,46 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearch } from "@/lib/research/handleResearch"; import { resolveTrack } from "@/lib/research/resolveTrack"; - -const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; +import { validateGetResearchTrackPlaylistsRequest } from "@/lib/research/validateGetResearchTrackPlaylistsRequest"; /** - * Track playlists handler — returns playlists featuring a specific track. - * Accepts a Chartmetric track ID, or a track name + optional artist for Spotify-powered lookup. + * GET /api/research/track/playlists + * + * Returns playlists featuring a specific track. Accepts a Chartmetric track ID + * directly, or resolves via track name + optional artist. * - * @param request - query params: id or q (+artist), platform, status, editorial, limit + * @param request - query params: id or q (+artist), platform, status, filter flags, pagination * @returns JSON playlist placements for the track or error */ export async function getResearchTrackPlaylistsHandler( request: NextRequest, ): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - const q = searchParams.get("q"); - const artist = searchParams.get("artist") || undefined; - - if (!id && !q) { - return NextResponse.json( - { status: "error", error: "id or q parameter is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const platform = searchParams.get("platform") || "spotify"; - if (!VALID_PLATFORMS.includes(platform)) { - return NextResponse.json( - { - status: "error", - error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, - }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const status = searchParams.get("status") || "current"; - if (status !== "current" && status !== "past") { - return NextResponse.json( - { status: "error", error: "status must be 'current' or 'past'" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - let trackId = id; - - if (!trackId) { - const resolved = await resolveTrack(q!, artist); - if (resolved.error) { - return NextResponse.json( - { status: "error", error: resolved.error }, - { status: 404, headers: getCorsHeaders() }, - ); - } - trackId = resolved.id; - } - - const queryParams: Record = {}; - const limit = searchParams.get("limit"); - if (limit) queryParams.limit = limit; - const offset = searchParams.get("offset"); - if (offset) queryParams.offset = offset; - const since = searchParams.get("since"); - if (since) queryParams.since = since; - const until = searchParams.get("until"); - if (until) queryParams.until = until; - const sortColumn = searchParams.get("sort"); - if (sortColumn) queryParams.sortColumn = sortColumn; - - const filterParams = [ - "editorial", - "indie", - "majorCurator", - "popularIndie", - "personalized", - "chart", - "newMusicFriday", - "thisIs", - "radio", - "brand", - ]; - - let hasFilters = false; - for (const param of filterParams) { - const value = searchParams.get(param); - if (value !== null) { - queryParams[param] = value; - hasFilters = true; + try { + const validated = await validateGetResearchTrackPlaylistsRequest(request); + if (validated instanceof NextResponse) return validated; + + let trackId = validated.id; + if (!trackId) { + const resolved = await resolveTrack(validated.q!, validated.artist); + if (resolved.error) return errorResponse(resolved.error, 404); + trackId = resolved.id; } - } - - if (!hasFilters) { - queryParams.editorial = "true"; - queryParams.indie = "true"; - queryParams.majorCurator = "true"; - queryParams.popularIndie = "true"; - } - - const result = await fetchChartmetric( - `/track/${trackId}/${platform}/${status}/playlists`, - queryParams, - ); - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: `Request failed with status ${result.status}` }, - { status: result.status, headers: getCorsHeaders() }, - ); - } + const result = await handleResearch({ + accountId: validated.accountId, + path: `/track/${trackId}/${validated.platform}/${validated.status}/playlists`, + query: { ...validated.pagination, ...validated.filters }, + }); - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - // Credit deduction failed but data was fetched — don't block - } + if ("error" in result) return errorResponse(result.error, result.status); - return NextResponse.json( - { - status: "success", + return successResponse({ placements: Array.isArray(result.data) ? result.data : [], - }, - { status: 200, headers: getCorsHeaders() }, - ); + }); + } catch (error) { + console.error("[ERROR] getResearchTrackPlaylistsHandler:", error); + return errorResponse("Internal error", 500); + } } diff --git a/lib/research/validateGetResearchLookupRequest.ts b/lib/research/validateGetResearchLookupRequest.ts new file mode 100644 index 000000000..497d438fb --- /dev/null +++ b/lib/research/validateGetResearchLookupRequest.ts @@ -0,0 +1,32 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; + +export type ValidatedGetResearchLookupRequest = { + accountId: string; + spotifyId: string; +}; + +/** + * Validates `GET /api/research/lookup` — auth + `url` (required Spotify artist + * URL). Extracts the Spotify artist ID from the URL. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchLookupRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const url = searchParams.get("url"); + if (!url) return errorResponse("url parameter is required", 400); + + const match = url.match(SPOTIFY_ARTIST_REGEX); + if (!match) return errorResponse("url must be a valid Spotify artist URL", 400); + + return { accountId: authResult.accountId, spotifyId: match[1] }; +} diff --git a/lib/research/validateGetResearchPlaylistRequest.ts b/lib/research/validateGetResearchPlaylistRequest.ts new file mode 100644 index 000000000..73856015f --- /dev/null +++ b/lib/research/validateGetResearchPlaylistRequest.ts @@ -0,0 +1,38 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + +export type ValidatedGetResearchPlaylistRequest = { + accountId: string; + platform: string; + id: string; +}; + +/** + * Validates `GET /api/research/playlist` — auth + required `platform` (one of + * spotify/applemusic/deezer/amazon/youtube) and `id` (numeric ID or playlist + * name; non-numeric values trigger a name-search fallback in the handler). + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchPlaylistRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + const id = searchParams.get("id"); + + if (!platform || !id) { + return errorResponse("platform and id parameters are required", 400); + } + if (!VALID_PLATFORMS.includes(platform)) { + return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); + } + + return { accountId: authResult.accountId, platform, id }; +} diff --git a/lib/research/validateGetResearchSearchRequest.ts b/lib/research/validateGetResearchSearchRequest.ts new file mode 100644 index 000000000..8a10a74d1 --- /dev/null +++ b/lib/research/validateGetResearchSearchRequest.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +export type ValidatedGetResearchSearchRequest = { + accountId: string; + q: string; + type: string; + limit: string; +}; + +/** + * Validates `GET /api/research/search` — auth + required `q` query param, with + * defaults for `type` ("artists") and `limit` ("10"). + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchSearchRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + if (!q) return errorResponse("q parameter is required", 400); + + return { + accountId: authResult.accountId, + q, + type: searchParams.get("type") || "artists", + limit: searchParams.get("limit") || "10", + }; +} diff --git a/lib/research/validateGetResearchTrackPlaylistsRequest.ts b/lib/research/validateGetResearchTrackPlaylistsRequest.ts new file mode 100644 index 000000000..806845702 --- /dev/null +++ b/lib/research/validateGetResearchTrackPlaylistsRequest.ts @@ -0,0 +1,100 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; +const FILTER_PARAMS = [ + "editorial", + "indie", + "majorCurator", + "popularIndie", + "personalized", + "chart", + "newMusicFriday", + "thisIs", + "radio", + "brand", +] as const; + +export type ValidatedGetResearchTrackPlaylistsRequest = { + accountId: string; + id: string | null; + q: string | null; + artist: string | undefined; + platform: string; + status: string; + filters: Record; + pagination: Record; +}; + +/** + * Validates `GET /api/research/track/playlists` — auth + requires either `id` + * or `q`. Validates `platform` (default "spotify") and `status` (default + * "current"). Collects optional filter flags (editorial, indie, majorCurator, + * popularIndie, personalized, chart, newMusicFriday, thisIs, radio, brand) and + * pagination/sort (limit, offset, since, until, sort). + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchTrackPlaylistsRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const q = searchParams.get("q"); + const artist = searchParams.get("artist") || undefined; + + if (!id && !q) return errorResponse("id or q parameter is required", 400); + + const platform = searchParams.get("platform") || "spotify"; + if (!VALID_PLATFORMS.includes(platform)) { + return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); + } + + const status = searchParams.get("status") || "current"; + if (status !== "current" && status !== "past") { + return errorResponse("status must be 'current' or 'past'", 400); + } + + const filters: Record = {}; + let hasFilters = false; + for (const param of FILTER_PARAMS) { + const value = searchParams.get(param); + if (value !== null) { + filters[param] = value; + hasFilters = true; + } + } + if (!hasFilters) { + filters.editorial = "true"; + filters.indie = "true"; + filters.majorCurator = "true"; + filters.popularIndie = "true"; + } + + const pagination: Record = {}; + const limit = searchParams.get("limit"); + if (limit) pagination.limit = limit; + const offset = searchParams.get("offset"); + if (offset) pagination.offset = offset; + const since = searchParams.get("since"); + if (since) pagination.since = since; + const until = searchParams.get("until"); + if (until) pagination.until = until; + const sortColumn = searchParams.get("sort"); + if (sortColumn) pagination.sortColumn = sortColumn; + + return { + accountId: authResult.accountId, + id, + q, + artist, + platform, + status, + filters, + pagination, + }; +} diff --git a/lib/research/validateGetResearchTrackRequest.ts b/lib/research/validateGetResearchTrackRequest.ts new file mode 100644 index 000000000..3c053431a --- /dev/null +++ b/lib/research/validateGetResearchTrackRequest.ts @@ -0,0 +1,26 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +export type ValidatedGetResearchTrackRequest = { + accountId: string; + q: string; +}; + +/** + * Validates `GET /api/research/track` — auth + required `q` query param. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchTrackRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + if (!q) return errorResponse("q parameter is required", 400); + + return { accountId: authResult.accountId, q }; +} From 81871c458735a87cb327831ebd87672336503234 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 16:58:28 -0500 Subject: [PATCH 21/28] refactor: charge credits for each Chartmetric hop in track+playlist lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both getResearchTrackHandler and getResearchPlaylistHandler do a name-resolution search before the detail fetch. Previously the resolve step used fetchChartmetric (no credit charge), so a name-based lookup cost the same as a direct-ID lookup (5 credits) despite hitting the upstream twice. Swap the resolve step to handleResearch so each successful upstream hit deducts 5 credits. Failed searches still deduct nothing (handleResearch skips deduction on non-200). Cost mapping: - Direct ID → 1 call, 5 credits (unchanged) - Name lookup → 2 calls, 10 credits (was 5) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/getResearchTrackHandler.test.ts | 53 +++++++++++-------- lib/research/getResearchPlaylistHandler.ts | 11 ++-- lib/research/getResearchTrackHandler.ts | 11 ++-- 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts index 31592f472..4c498887e 100644 --- a/lib/research/__tests__/getResearchTrackHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -4,7 +4,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackHandler } from "../getResearchTrackHandler"; import { validateGetResearchTrackRequest } from "../validateGetResearchTrackRequest"; import { handleResearch } from "../handleResearch"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -18,10 +17,6 @@ vi.mock("../handleResearch", () => ({ handleResearch: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), -})); - describe("getResearchTrackHandler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -40,7 +35,10 @@ describe("getResearchTrackHandler", () => { }); it("returns 'Track search failed' when search errors", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ data: null, status: 502 }); + vi.mocked(handleResearch).mockResolvedValueOnce({ + error: "Request failed with status 502", + status: 502, + }); const req = new NextRequest("http://localhost/api/research/track?q=foo"); const res = await getResearchTrackHandler(req); expect(res.status).toBe(502); @@ -49,7 +47,7 @@ describe("getResearchTrackHandler", () => { }); it("returns 404 when no track matches", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ data: { tracks: [] }, status: 200 }); + vi.mocked(handleResearch).mockResolvedValueOnce({ data: { tracks: [] } }); const req = new NextRequest("http://localhost/api/research/track?q=nothing"); const res = await getResearchTrackHandler(req); expect(res.status).toBe(404); @@ -58,14 +56,9 @@ describe("getResearchTrackHandler", () => { }); it("returns 'Failed to fetch track details' on detail error", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ - data: { tracks: [{ id: 12345 }] }, - status: 200, - }); - vi.mocked(handleResearch).mockResolvedValue({ - error: "Request failed with status 503", - status: 503, - }); + vi.mocked(handleResearch) + .mockResolvedValueOnce({ data: { tracks: [{ id: 12345 }] } }) + .mockResolvedValueOnce({ error: "Request failed with status 503", status: 503 }); const req = new NextRequest("http://localhost/api/research/track?q=foo"); const res = await getResearchTrackHandler(req); expect(res.status).toBe(503); @@ -74,14 +67,9 @@ describe("getResearchTrackHandler", () => { }); it("returns 200 with track data on success", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ - data: { tracks: [{ id: 12345 }] }, - status: 200, - }); - vi.mocked(handleResearch).mockResolvedValue({ - data: { name: "Hotline Bling", artist: "Drake", id: 12345 }, - }); - + vi.mocked(handleResearch) + .mockResolvedValueOnce({ data: { tracks: [{ id: 12345 }] } }) + .mockResolvedValueOnce({ data: { name: "Hotline Bling", artist: "Drake", id: 12345 } }); const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); const res = await getResearchTrackHandler(req); expect(res.status).toBe(200); @@ -89,4 +77,23 @@ describe("getResearchTrackHandler", () => { expect(body.status).toBe("success"); expect(body.name).toBe("Hotline Bling"); }); + + it("invokes handleResearch twice on successful lookup so credits are deducted for both hops", async () => { + vi.mocked(handleResearch) + .mockResolvedValueOnce({ data: { tracks: [{ id: 999 }] } }) + .mockResolvedValueOnce({ data: { id: 999 } }); + const req = new NextRequest("http://localhost/api/research/track?q=foo"); + await getResearchTrackHandler(req); + + expect(handleResearch).toHaveBeenCalledTimes(2); + expect(handleResearch).toHaveBeenNthCalledWith(1, { + accountId: "test-id", + path: "/search", + query: { q: "Hotline Bling", type: "tracks", limit: "1" }, + }); + expect(handleResearch).toHaveBeenNthCalledWith(2, { + accountId: "test-id", + path: "/track/999", + }); + }); }); diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts index 7e83c3e57..dd46cc77e 100644 --- a/lib/research/getResearchPlaylistHandler.ts +++ b/lib/research/getResearchPlaylistHandler.ts @@ -1,5 +1,4 @@ import { type NextRequest, NextResponse } from "next/server"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { errorResponse } from "@/lib/networking/errorResponse"; import { successResponse } from "@/lib/networking/successResponse"; import { handleResearch } from "@/lib/research/handleResearch"; @@ -23,13 +22,13 @@ export async function getResearchPlaylistHandler(request: NextRequest): Promise< let playlistId = id; if (!/^\d+$/.test(id)) { - const searchResult = await fetchChartmetric("/search", { - q: id, - type: "playlists", - limit: "1", + const searchResult = await handleResearch({ + accountId, + path: "/search", + query: { q: id, type: "playlists", limit: "1" }, }); - if (searchResult.status !== 200) { + if ("error" in searchResult) { return errorResponse( `Search failed with status ${searchResult.status}`, searchResult.status, diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts index 886aaa468..6ff51c1eb 100644 --- a/lib/research/getResearchTrackHandler.ts +++ b/lib/research/getResearchTrackHandler.ts @@ -1,5 +1,4 @@ import { type NextRequest, NextResponse } from "next/server"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { errorResponse } from "@/lib/networking/errorResponse"; import { successResponse } from "@/lib/networking/successResponse"; import { handleResearch } from "@/lib/research/handleResearch"; @@ -19,13 +18,13 @@ export async function getResearchTrackHandler(request: NextRequest): Promise Date: Wed, 15 Apr 2026 17:15:16 -0500 Subject: [PATCH 22/28] refactor(review): address 9 review comments - Rename `gate` -> `validated` in 3 composed validators (KISS) - Spotify artist URL regex only appears in validateGetResearchLookupRequest; no duplication, left as-is - resolveTrack now takes accountId and calls handleResearch so Chartmetric searches deduct credits (was hand-rolled fetchChartmetric) - Extract validator files for 4 POST research handlers: validatePostResearchWebRequest, ...PeopleRequest, ...ExtractRequest, ...EnrichRequest. Handlers now compose validator + existing AI-provider call inside try/catch with Promise return types. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../getResearchTrackPlaylistsHandler.test.ts | 2 +- .../__tests__/postResearchWebHandler.test.ts | 28 ++--- lib/research/__tests__/resolveTrack.test.ts | 113 ++++++++++++++++++ .../validatePostResearchEnrichRequest.test.ts | 62 ++++++++++ ...validatePostResearchExtractRequest.test.ts | 63 ++++++++++ .../validatePostResearchPeopleRequest.test.ts | 50 ++++++++ .../validatePostResearchWebRequest.test.ts | 51 ++++++++ .../getResearchTrackPlaylistsHandler.ts | 2 +- lib/research/postResearchEnrichHandler.ts | 54 +++------ lib/research/postResearchExtractHandler.ts | 55 +++------ lib/research/postResearchPeopleHandler.ts | 47 ++------ lib/research/postResearchWebHandler.ts | 56 +++------ lib/research/resolveTrack.ts | 22 +++- .../validateGetResearchMetricsRequest.ts | 6 +- .../validateGetResearchPlaylistsRequest.ts | 6 +- .../validateGetResearchSimilarRequest.ts | 6 +- .../validatePostResearchEnrichRequest.ts | 36 ++++++ .../validatePostResearchExtractRequest.ts | 36 ++++++ .../validatePostResearchPeopleRequest.ts | 34 ++++++ .../validatePostResearchWebRequest.ts | 36 ++++++ 20 files changed, 572 insertions(+), 193 deletions(-) create mode 100644 lib/research/__tests__/resolveTrack.test.ts create mode 100644 lib/research/__tests__/validatePostResearchEnrichRequest.test.ts create mode 100644 lib/research/__tests__/validatePostResearchExtractRequest.test.ts create mode 100644 lib/research/__tests__/validatePostResearchPeopleRequest.test.ts create mode 100644 lib/research/__tests__/validatePostResearchWebRequest.test.ts create mode 100644 lib/research/validatePostResearchEnrichRequest.ts create mode 100644 lib/research/validatePostResearchExtractRequest.ts create mode 100644 lib/research/validatePostResearchPeopleRequest.ts create mode 100644 lib/research/validatePostResearchWebRequest.ts diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 244d92fb6..e676f5237 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -88,7 +88,7 @@ describe("getResearchTrackPlaylistsHandler", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.placements).toHaveLength(1); - expect(vi.mocked(resolveTrack)).toHaveBeenCalledWith("God's Plan", "Drake"); + expect(vi.mocked(resolveTrack)).toHaveBeenCalledWith("God's Plan", "Drake", "test-id"); }); it("returns 404 when track name search finds nothing", async () => { diff --git a/lib/research/__tests__/postResearchWebHandler.test.ts b/lib/research/__tests__/postResearchWebHandler.test.ts index c7403e843..2dbbde672 100644 --- a/lib/research/__tests__/postResearchWebHandler.test.ts +++ b/lib/research/__tests__/postResearchWebHandler.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { postResearchWebHandler } from "../postResearchWebHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validatePostResearchWebRequest } from "../validatePostResearchWebRequest"; import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; @@ -10,8 +10,8 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), +vi.mock("../validatePostResearchWebRequest", () => ({ + validatePostResearchWebRequest: vi.fn(), })); vi.mock("@/lib/perplexity/searchPerplexity", () => ({ @@ -31,24 +31,21 @@ describe("postResearchWebHandler", () => { vi.clearAllMocks(); }); - it("returns 401 when auth fails", async () => { - const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + it("returns validator error response (e.g. 401)", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validatePostResearchWebRequest).mockResolvedValue(err); const req = new NextRequest("http://localhost/api/research/web", { method: "POST", body: JSON.stringify({ query: "test" }), }); const res = await postResearchWebHandler(req); - expect(res.status).toBe(401); + expect(res).toBe(err); }); - it("returns 400 when body is missing query", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); + it("returns validator 400 when body is invalid", async () => { + const err = NextResponse.json({ error: "bad" }, { status: 400 }); + vi.mocked(validatePostResearchWebRequest).mockResolvedValue(err); const req = new NextRequest("http://localhost/api/research/web", { method: "POST", @@ -59,10 +56,9 @@ describe("postResearchWebHandler", () => { }); it("returns 200 with results and formatted markdown on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ + vi.mocked(validatePostResearchWebRequest).mockResolvedValue({ accountId: "test-id", - orgId: null, - authToken: "token", + query: "latest music trends", }); const mockResults = [{ title: "Test", url: "https://example.com", snippet: "..." }]; diff --git a/lib/research/__tests__/resolveTrack.test.ts b/lib/research/__tests__/resolveTrack.test.ts new file mode 100644 index 000000000..987e88917 --- /dev/null +++ b/lib/research/__tests__/resolveTrack.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { resolveTrack } from "../resolveTrack"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getSearch from "@/lib/spotify/getSearch"; +import { handleResearch } from "../handleResearch"; + +vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() })); +vi.mock("@/lib/spotify/getSearch", () => ({ default: vi.fn() })); +vi.mock("../handleResearch", () => ({ handleResearch: vi.fn() })); + +describe("resolveTrack", () => { + const accountId = "acct-1"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateAccessToken).mockResolvedValue({ + access_token: "token", + } as never); + }); + + it("returns error when Spotify auth fails", async () => { + vi.mocked(generateAccessToken).mockResolvedValue({ error: "nope" } as never); + const result = await resolveTrack("q", undefined, accountId); + expect("error" in result && result.error).toBe("Failed to authenticate with Spotify"); + }); + + it("returns error when Spotify search fails", async () => { + vi.mocked(getSearch).mockResolvedValue({ error: "fail", data: null } as never); + const result = await resolveTrack("q", undefined, accountId); + expect("error" in result && result.error).toBe("Spotify search failed"); + }); + + it("returns error when no track found", async () => { + vi.mocked(getSearch).mockResolvedValue({ + data: { tracks: { items: [] } }, + } as never); + const result = await resolveTrack("q", "Drake", accountId); + expect("error" in result && result.error).toContain("No track found"); + }); + + it("calls handleResearch with ISRC path and accountId when ISRC present", async () => { + vi.mocked(getSearch).mockResolvedValue({ + data: { + tracks: { + items: [{ id: "sp1", name: "T", external_ids: { isrc: "ISRC123" } }], + }, + }, + } as never); + vi.mocked(handleResearch).mockResolvedValue({ + data: { chartmetric_ids: [42] }, + }); + + const result = await resolveTrack("q", undefined, accountId); + expect(vi.mocked(handleResearch)).toHaveBeenCalledWith({ + accountId, + path: "/track/isrc/ISRC123/get-ids", + }); + expect("id" in result && result.id).toBe("42"); + }); + + it("falls back to spotify-id path via handleResearch when ISRC lookup yields no id", async () => { + vi.mocked(getSearch).mockResolvedValue({ + data: { + tracks: { + items: [{ id: "sp1", name: "T", external_ids: { isrc: "ISRC123" } }], + }, + }, + } as never); + vi.mocked(handleResearch) + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ data: { chartmetric_ids: [99] } }); + + const result = await resolveTrack("q", undefined, accountId); + expect(vi.mocked(handleResearch)).toHaveBeenNthCalledWith(2, { + accountId, + path: "/track/spotify/sp1/get-ids", + }); + expect("id" in result && result.id).toBe("99"); + }); + + it("uses spotify-id path when no ISRC is present", async () => { + vi.mocked(getSearch).mockResolvedValue({ + data: { + tracks: { items: [{ id: "sp9", name: "T", external_ids: {} }] }, + }, + } as never); + vi.mocked(handleResearch).mockResolvedValue({ + data: { chartmetric_ids: [7] }, + }); + + const result = await resolveTrack("q", undefined, accountId); + expect(vi.mocked(handleResearch)).toHaveBeenCalledWith({ + accountId, + path: "/track/spotify/sp9/get-ids", + }); + expect("id" in result && result.id).toBe("7"); + }); + + it("returns error when neither ISRC nor spotify-id resolves", async () => { + vi.mocked(getSearch).mockResolvedValue({ + data: { + tracks: { + items: [{ id: "sp1", name: "Song", external_ids: { isrc: "X" } }], + }, + }, + } as never); + vi.mocked(handleResearch).mockResolvedValue({ data: {} }); + + const result = await resolveTrack("q", undefined, accountId); + expect("error" in result && result.error).toContain("Could not resolve Chartmetric ID"); + }); +}); diff --git a/lib/research/__tests__/validatePostResearchEnrichRequest.test.ts b/lib/research/__tests__/validatePostResearchEnrichRequest.test.ts new file mode 100644 index 000000000..fab935925 --- /dev/null +++ b/lib/research/__tests__/validatePostResearchEnrichRequest.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validatePostResearchEnrichRequest } from "../validatePostResearchEnrichRequest"; +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() })); + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/research/enrich", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("validatePostResearchEnrichRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acct", + orgId: null, + authToken: "t", + } as never); + }); + + it("returns auth error", async () => { + const err = NextResponse.json({ error: "nope" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(err); + const res = await validatePostResearchEnrichRequest(req({ input: "x", schema: {} })); + expect(res).toBe(err); + }); + + it("returns 400 when input missing", async () => { + const res = await validatePostResearchEnrichRequest(req({ schema: {} })); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns 400 when schema missing", async () => { + const res = await validatePostResearchEnrichRequest(req({ input: "x" })); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns 400 for invalid processor", async () => { + const res = await validatePostResearchEnrichRequest( + req({ input: "x", schema: {}, processor: "mega" }), + ); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns validated payload with default processor", async () => { + const res = await validatePostResearchEnrichRequest(req({ input: "x", schema: { k: 1 } })); + expect(res).toEqual({ + accountId: "acct", + input: "x", + schema: { k: 1 }, + processor: "base", + }); + }); +}); diff --git a/lib/research/__tests__/validatePostResearchExtractRequest.test.ts b/lib/research/__tests__/validatePostResearchExtractRequest.test.ts new file mode 100644 index 000000000..d62c56c77 --- /dev/null +++ b/lib/research/__tests__/validatePostResearchExtractRequest.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validatePostResearchExtractRequest } from "../validatePostResearchExtractRequest"; +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() })); + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/research/extract", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("validatePostResearchExtractRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acct", + orgId: null, + authToken: "t", + } as never); + }); + + it("returns auth error", async () => { + const err = NextResponse.json({ error: "nope" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(err); + const res = await validatePostResearchExtractRequest(req({ urls: ["https://a.com"] })); + expect(res).toBe(err); + }); + + it("returns 400 when urls is missing", async () => { + const res = await validatePostResearchExtractRequest(req({})); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns 400 when urls is empty", async () => { + const res = await validatePostResearchExtractRequest(req({ urls: [] })); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns 400 when too many urls", async () => { + const urls = Array.from({ length: 11 }, (_, i) => `https://a.com/${i}`); + const res = await validatePostResearchExtractRequest(req({ urls })); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns validated payload on success", async () => { + const res = await validatePostResearchExtractRequest( + req({ urls: ["https://a.com"], objective: "obj", full_content: true }), + ); + expect(res).toEqual({ + accountId: "acct", + urls: ["https://a.com"], + objective: "obj", + full_content: true, + }); + }); +}); diff --git a/lib/research/__tests__/validatePostResearchPeopleRequest.test.ts b/lib/research/__tests__/validatePostResearchPeopleRequest.test.ts new file mode 100644 index 000000000..458f3f97f --- /dev/null +++ b/lib/research/__tests__/validatePostResearchPeopleRequest.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validatePostResearchPeopleRequest } from "../validatePostResearchPeopleRequest"; +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() })); + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/research/people", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("validatePostResearchPeopleRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acct", + orgId: null, + authToken: "t", + } as never); + }); + + it("returns auth error", async () => { + const err = NextResponse.json({ error: "nope" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(err); + const res = await validatePostResearchPeopleRequest(req({ query: "x" })); + expect(res).toBe(err); + }); + + it("returns 400 when query missing", async () => { + const res = await validatePostResearchPeopleRequest(req({})); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns 400 when num_results > 100", async () => { + const res = await validatePostResearchPeopleRequest(req({ query: "x", num_results: 101 })); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns validated payload on success", async () => { + const res = await validatePostResearchPeopleRequest(req({ query: "x", num_results: 5 })); + expect(res).toEqual({ accountId: "acct", query: "x", num_results: 5 }); + }); +}); diff --git a/lib/research/__tests__/validatePostResearchWebRequest.test.ts b/lib/research/__tests__/validatePostResearchWebRequest.test.ts new file mode 100644 index 000000000..ae3f43b28 --- /dev/null +++ b/lib/research/__tests__/validatePostResearchWebRequest.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validatePostResearchWebRequest } from "../validatePostResearchWebRequest"; +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() })); + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/research/web", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("validatePostResearchWebRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acct", + orgId: null, + authToken: "t", + } as never); + }); + + it("returns auth error response", async () => { + const err = NextResponse.json({ error: "nope" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(err); + const res = await validatePostResearchWebRequest(req({ query: "x" })); + expect(res).toBe(err); + }); + + it("returns 400 when query is missing", async () => { + const res = await validatePostResearchWebRequest(req({})); + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns 400 when max_results is out of range", async () => { + const res = await validatePostResearchWebRequest(req({ query: "x", max_results: 99 })); + expect((res as NextResponse).status).toBe(400); + }); + + it("returns validated payload on success", async () => { + const res = await validatePostResearchWebRequest(req({ query: "x", country: "US" })); + expect(res).toEqual({ accountId: "acct", query: "x", country: "US" }); + }); +}); diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts index 5a1d25465..1ec1d57aa 100644 --- a/lib/research/getResearchTrackPlaylistsHandler.ts +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -23,7 +23,7 @@ export async function getResearchTrackPlaylistsHandler( let trackId = validated.id; if (!trackId) { - const resolved = await resolveTrack(validated.q!, validated.artist); + const resolved = await resolveTrack(validated.q!, validated.artist, validated.accountId); if (resolved.error) return errorResponse(resolved.error, 404); trackId = resolved.id; } diff --git a/lib/research/postResearchEnrichHandler.ts b/lib/research/postResearchEnrichHandler.ts index 9cd984934..702552b23 100644 --- a/lib/research/postResearchEnrichHandler.ts +++ b/lib/research/postResearchEnrichHandler.ts @@ -1,15 +1,10 @@ import { type NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; import { deductCredits } from "@/lib/credits/deductCredits"; import { enrichEntity } from "@/lib/parallel/enrichEntity"; - -const bodySchema = z.object({ - input: z.string().min(1, "input is required"), - schema: z.record(z.string(), z.unknown()), - processor: z.enum(["base", "core", "ultra"]).optional().default("base"), -}); +import { validatePostResearchEnrichRequest } from "@/lib/research/validatePostResearchEnrichRequest"; /** * POST /api/research/enrich @@ -22,25 +17,14 @@ const bodySchema = z.object({ * @returns JSON success or error response */ export async function postResearchEnrichHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - let body: z.infer; try { - body = bodySchema.parse(await request.json()); - } catch (err) { - const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; - return NextResponse.json( - { status: "error", error: message ?? "Invalid request body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + const validated = await validatePostResearchEnrichRequest(request); + if (validated instanceof NextResponse) return validated; - const creditCost = body.processor === "ultra" ? 25 : body.processor === "core" ? 10 : 5; + const creditCost = + validated.processor === "ultra" ? 25 : validated.processor === "core" ? 10 : 5; - try { - const result = await enrichEntity(body.input, body.schema, body.processor); + const result = await enrichEntity(validated.input, validated.schema, validated.processor); if (result.status === "timeout") { return NextResponse.json( @@ -54,26 +38,16 @@ export async function postResearchEnrichHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - let body: z.infer; try { - body = bodySchema.parse(await request.json()); - } catch (err) { - const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; - return NextResponse.json( - { status: "error", error: message ?? "Invalid request body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + const validated = await validatePostResearchExtractRequest(request); + if (validated instanceof NextResponse) return validated; - try { - const result = await extractUrl(body.urls, body.objective, body.full_content); + const result = await extractUrl(validated.urls, validated.objective, validated.full_content); try { - await deductCredits({ accountId, creditsToDeduct: 5 * body.urls.length }); + await deductCredits({ + accountId: validated.accountId, + creditsToDeduct: 5 * validated.urls.length, + }); } catch { // Credit deduction failed but data was fetched — log but don't block } - return NextResponse.json( - { - status: "success", - results: result.results, - errors: result.errors.length > 0 ? result.errors : undefined, - }, - { status: 200, headers: getCorsHeaders() }, - ); + return successResponse({ + results: result.results, + errors: result.errors.length > 0 ? result.errors : undefined, + }); } catch (error) { - return NextResponse.json( - { - status: "error", - error: error instanceof Error ? error.message : "Extract failed", - }, - { status: 500, headers: getCorsHeaders() }, - ); + return errorResponse(error instanceof Error ? error.message : "Extract failed", 500); } } diff --git a/lib/research/postResearchPeopleHandler.ts b/lib/research/postResearchPeopleHandler.ts index 0cc1c2d65..fd224bdfc 100644 --- a/lib/research/postResearchPeopleHandler.ts +++ b/lib/research/postResearchPeopleHandler.ts @@ -1,14 +1,9 @@ import { type NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; import { deductCredits } from "@/lib/credits/deductCredits"; import { searchPeople } from "@/lib/exa/searchPeople"; - -const bodySchema = z.object({ - query: z.string().min(1, "query is required"), - num_results: z.coerce.number().int().min(1).max(100).optional(), -}); +import { validatePostResearchPeopleRequest } from "@/lib/research/validatePostResearchPeopleRequest"; /** * POST /api/research/people @@ -21,44 +16,20 @@ const bodySchema = z.object({ * @returns JSON success or error response */ export async function postResearchPeopleHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - let body: z.infer; try { - body = bodySchema.parse(await request.json()); - } catch (err) { - const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; - return NextResponse.json( - { status: "error", error: message ?? "Invalid request body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + const validated = await validatePostResearchPeopleRequest(request); + if (validated instanceof NextResponse) return validated; - try { - const result = await searchPeople(body.query, body.num_results ?? 10); + const result = await searchPeople(validated.query, validated.num_results ?? 10); try { - await deductCredits({ accountId, creditsToDeduct: 5 }); + await deductCredits({ accountId: validated.accountId, creditsToDeduct: 5 }); } catch { // Credit deduction failed but data was fetched — log but don't block } - return NextResponse.json( - { - status: "success", - results: result.results, - }, - { status: 200, headers: getCorsHeaders() }, - ); + return successResponse({ results: result.results }); } catch (error) { - return NextResponse.json( - { - status: "error", - error: error instanceof Error ? error.message : "People search failed", - }, - { status: 500, headers: getCorsHeaders() }, - ); + return errorResponse(error instanceof Error ? error.message : "People search failed", 500); } } diff --git a/lib/research/postResearchWebHandler.ts b/lib/research/postResearchWebHandler.ts index 5b69533f2..989234dcc 100644 --- a/lib/research/postResearchWebHandler.ts +++ b/lib/research/postResearchWebHandler.ts @@ -1,16 +1,10 @@ import { type NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; import { deductCredits } from "@/lib/credits/deductCredits"; import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; - -const bodySchema = z.object({ - query: z.string().min(1, "query is required"), - max_results: z.coerce.number().int().min(1).max(20).optional(), - country: z.string().length(2).optional(), -}); +import { validatePostResearchWebRequest } from "@/lib/research/validatePostResearchWebRequest"; /** * Web search handler — queries Perplexity for real-time web results with formatted markdown output. @@ -19,52 +13,30 @@ const bodySchema = z.object({ * @returns JSON search results with formatted markdown or error */ export async function postResearchWebHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - let body: z.infer; try { - body = bodySchema.parse(await request.json()); - } catch (err) { - const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; - return NextResponse.json( - { status: "error", error: message ?? "Invalid request body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + const validated = await validatePostResearchWebRequest(request); + if (validated instanceof NextResponse) return validated; - try { const searchResponse = await searchPerplexity({ - query: body.query, - max_results: body.max_results ?? 10, + query: validated.query, + max_results: validated.max_results ?? 10, max_tokens_per_page: 1024, - ...(body.country && { country: body.country }), + ...(validated.country && { country: validated.country }), }); const formatted = formatSearchResultsAsMarkdown(searchResponse); try { - await deductCredits({ accountId, creditsToDeduct: 5 }); + await deductCredits({ accountId: validated.accountId, creditsToDeduct: 5 }); } catch { // Credit deduction failed but data was fetched — log but don't block } - return NextResponse.json( - { - status: "success", - results: searchResponse.results, - formatted, - }, - { status: 200, headers: getCorsHeaders() }, - ); + return successResponse({ + results: searchResponse.results, + formatted, + }); } catch (error) { - return NextResponse.json( - { - status: "error", - error: error instanceof Error ? error.message : "Web search failed", - }, - { status: 500, headers: getCorsHeaders() }, - ); + return errorResponse(error instanceof Error ? error.message : "Web search failed", 500); } } diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 4c721ee3c..de2ff44c5 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,6 +1,6 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { handleResearch } from "@/lib/research/handleResearch"; interface GetIdsResponse { chartmetric_ids?: number[]; @@ -12,10 +12,14 @@ interface GetIdsResponse { * Uses Spotify search for accurate matching, gets the ISRC, then maps * to a Chartmetric ID via /track/isrc/{isrc}/get-ids. * Works across all platforms since ISRC is a universal identifier. + * + * Chartmetric calls are routed through {@link handleResearch} so each + * lookup properly deducts credits from the caller's account. */ export async function resolveTrack( q: string, - artist?: string, + artist: string | undefined, + accountId: string, ): Promise<{ id: string; error?: never } | { id?: never; error: string }> { const searchQuery = artist ? `${q} artist:${artist}` : q; @@ -50,8 +54,11 @@ export async function resolveTrack( const isrc = spotifyTrack.external_ids?.isrc; if (isrc) { - const result = await fetchChartmetric(`/track/isrc/${isrc}/get-ids`); - if (result.status === 200) { + const result = await handleResearch({ + accountId, + path: `/track/isrc/${isrc}/get-ids`, + }); + if ("data" in result) { const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; const cmId = ids?.chartmetric_ids?.[0]; if (cmId) return { id: String(cmId) }; @@ -59,8 +66,11 @@ export async function resolveTrack( } const spotifyId = spotifyTrack.id; - const result = await fetchChartmetric(`/track/spotify/${spotifyId}/get-ids`); - if (result.status === 200) { + const result = await handleResearch({ + accountId, + path: `/track/spotify/${spotifyId}/get-ids`, + }); + if ("data" in result) { const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; const cmId = ids?.chartmetric_ids?.[0]; if (cmId) return { id: String(cmId) }; diff --git a/lib/research/validateGetResearchMetricsRequest.ts b/lib/research/validateGetResearchMetricsRequest.ts index 6b9b2728d..a85207767 100644 --- a/lib/research/validateGetResearchMetricsRequest.ts +++ b/lib/research/validateGetResearchMetricsRequest.ts @@ -39,8 +39,8 @@ export async function validateGetResearchMetricsRequest( return errorResponse(`Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}`, 400); } - const gate = await validateArtistRequest(request); - if (gate instanceof NextResponse) return gate; + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - return { ...gate, source }; + return { ...validated, source }; } diff --git a/lib/research/validateGetResearchPlaylistsRequest.ts b/lib/research/validateGetResearchPlaylistsRequest.ts index d731bab3f..b8963f402 100644 --- a/lib/research/validateGetResearchPlaylistsRequest.ts +++ b/lib/research/validateGetResearchPlaylistsRequest.ts @@ -35,8 +35,8 @@ export async function validateGetResearchPlaylistsRequest( return errorResponse(`Invalid status. Must be one of: ${VALID_STATUSES.join(", ")}`, 400); } - const gate = await validateArtistRequest(request); - if (gate instanceof NextResponse) return gate; + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - return { ...gate, platform, status }; + return { ...validated, platform, status }; } diff --git a/lib/research/validateGetResearchSimilarRequest.ts b/lib/research/validateGetResearchSimilarRequest.ts index 933802780..5a6fbbbe7 100644 --- a/lib/research/validateGetResearchSimilarRequest.ts +++ b/lib/research/validateGetResearchSimilarRequest.ts @@ -45,8 +45,8 @@ export async function validateGetResearchSimilarRequest( axes[axis] = raw as Level; } - const gate = await validateArtistRequest(request); - if (gate instanceof NextResponse) return gate; + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; - return { ...gate, ...axes, limit: searchParams.get("limit") ?? undefined }; + return { ...validated, ...axes, limit: searchParams.get("limit") ?? undefined }; } diff --git a/lib/research/validatePostResearchEnrichRequest.ts b/lib/research/validatePostResearchEnrichRequest.ts new file mode 100644 index 000000000..3ac97c96c --- /dev/null +++ b/lib/research/validatePostResearchEnrichRequest.ts @@ -0,0 +1,36 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const bodySchema = z.object({ + input: z.string().min(1, "input is required"), + schema: z.record(z.string(), z.unknown()), + processor: z.enum(["base", "core", "ultra"]).optional().default("base"), +}); + +export type ValidatedPostResearchEnrichRequest = { + accountId: string; + input: string; + schema: Record; + processor: "base" | "core" | "ultra"; +}; + +/** + * Validates `POST /api/research/enrich` — auth + body (`input` and `schema` + * required, optional `processor` defaulting to `"base"`). + */ +export async function validatePostResearchEnrichRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const body = await request.json().catch(() => null); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400); + } + + return { accountId: authResult.accountId, ...parsed.data }; +} diff --git a/lib/research/validatePostResearchExtractRequest.ts b/lib/research/validatePostResearchExtractRequest.ts new file mode 100644 index 000000000..41753c5eb --- /dev/null +++ b/lib/research/validatePostResearchExtractRequest.ts @@ -0,0 +1,36 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const bodySchema = z.object({ + urls: z.array(z.string().min(1)).min(1).max(10), + objective: z.string().optional(), + full_content: z.boolean().optional(), +}); + +export type ValidatedPostResearchExtractRequest = { + accountId: string; + urls: string[]; + objective?: string; + full_content?: boolean; +}; + +/** + * Validates `POST /api/research/extract` — auth + body (`urls` 1..10 required, + * optional `objective`, `full_content`). + */ +export async function validatePostResearchExtractRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const body = await request.json().catch(() => null); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400); + } + + return { accountId: authResult.accountId, ...parsed.data }; +} diff --git a/lib/research/validatePostResearchPeopleRequest.ts b/lib/research/validatePostResearchPeopleRequest.ts new file mode 100644 index 000000000..6db8cb423 --- /dev/null +++ b/lib/research/validatePostResearchPeopleRequest.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), + num_results: z.coerce.number().int().min(1).max(100).optional(), +}); + +export type ValidatedPostResearchPeopleRequest = { + accountId: string; + query: string; + num_results?: number; +}; + +/** + * Validates `POST /api/research/people` — auth + body (`query` required, + * optional `num_results`). + */ +export async function validatePostResearchPeopleRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const body = await request.json().catch(() => null); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400); + } + + return { accountId: authResult.accountId, ...parsed.data }; +} diff --git a/lib/research/validatePostResearchWebRequest.ts b/lib/research/validatePostResearchWebRequest.ts new file mode 100644 index 000000000..09757852d --- /dev/null +++ b/lib/research/validatePostResearchWebRequest.ts @@ -0,0 +1,36 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), + max_results: z.coerce.number().int().min(1).max(20).optional(), + country: z.string().length(2).optional(), +}); + +export type ValidatedPostResearchWebRequest = { + accountId: string; + query: string; + max_results?: number; + country?: string; +}; + +/** + * Validates `POST /api/research/web` — auth + body (`query` required, + * optional `max_results`, `country`). + */ +export async function validatePostResearchWebRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const body = await request.json().catch(() => null); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400); + } + + return { accountId: authResult.accountId, ...parsed.data }; +} From fef9d7c76226892c8b3b7fdc01cb7f157b422250 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 15 Apr 2026 18:02:43 -0500 Subject: [PATCH 23/28] fix(review): tighten validateGetResearchCuratorRequest with clear errors Chartmetric's /curator/:platform/:id endpoint only supports spotify, applemusic, and deezer and requires a numeric curator id. Callers were passing string handles like "spotify" / "filtr" and getting opaque upstream 400s back. Now reject up front with helpful messages: - "Invalid platform. Must be one of: spotify, applemusic, deezer" - "id must be a numeric Chartmetric curator ID (e.g. 2 for Spotify)" Matches the tightened docs spec in recoupable/docs#137. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../validateGetResearchCuratorRequest.test.ts | 53 ++++++++++++++++--- .../validateGetResearchCuratorRequest.ts | 22 +++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts index 949b7d374..cb37e52cf 100644 --- a/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts @@ -25,17 +25,21 @@ describe("validateGetResearchCuratorRequest", () => { const errResp = NextResponse.json({ error: "no" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(errResp); const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?platform=spotify&id=123"), + new NextRequest("http://localhost/api/research/curator?platform=spotify&id=2"), ); expect(result).toBe(errResp); }); it("returns 400 when platform is missing", async () => { const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?id=123"), + new NextRequest("http://localhost/api/research/curator?id=2"), ); expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("platform parameter is required"); + } }); it("returns 400 when id is missing", async () => { @@ -43,13 +47,50 @@ describe("validateGetResearchCuratorRequest", () => { new NextRequest("http://localhost/api/research/curator?platform=spotify"), ); expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("id parameter is required"); + } + }); + + it("returns 400 when platform is not spotify/applemusic/deezer", async () => { + const result = await validateGetResearchCuratorRequest( + new NextRequest("http://localhost/api/research/curator?platform=youtube&id=2"), + ); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("Invalid platform. Must be one of: spotify, applemusic, deezer"); + } + }); + + it("returns 400 when id is non-numeric", async () => { + const result = await validateGetResearchCuratorRequest( + new NextRequest("http://localhost/api/research/curator?platform=spotify&id=spotify"), + ); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("id must be a numeric Chartmetric curator ID (e.g. 2 for Spotify)"); + } }); it("returns accountId, platform, id on success", async () => { const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?platform=spotify&id=abc123"), + new NextRequest("http://localhost/api/research/curator?platform=spotify&id=2"), ); - expect(result).toEqual({ accountId: "acc-1", platform: "spotify", id: "abc123" }); + expect(result).toEqual({ accountId: "acc-1", platform: "spotify", id: "2" }); + }); + + it("accepts applemusic and deezer as valid platforms", async () => { + for (const platform of ["applemusic", "deezer"] as const) { + const result = await validateGetResearchCuratorRequest( + new NextRequest(`http://localhost/api/research/curator?platform=${platform}&id=1000`), + ); + expect(result).toEqual({ accountId: "acc-1", platform, id: "1000" }); + } }); }); diff --git a/lib/research/validateGetResearchCuratorRequest.ts b/lib/research/validateGetResearchCuratorRequest.ts index c039cb932..08386ae84 100644 --- a/lib/research/validateGetResearchCuratorRequest.ts +++ b/lib/research/validateGetResearchCuratorRequest.ts @@ -2,15 +2,18 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { errorResponse } from "@/lib/networking/errorResponse"; +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer"] as const; +type Platform = (typeof VALID_PLATFORMS)[number]; + export type ValidatedGetResearchCuratorRequest = { accountId: string; - platform: string; + platform: Platform; id: string; }; /** - * Validates `GET /api/research/curator` — auth + required `platform` and `id` - * query params. + * Validates `GET /api/research/curator` — auth + required `platform` (enum) + * and `id` (numeric Chartmetric curator ID). * * @param request - The incoming HTTP request. */ @@ -24,9 +27,16 @@ export async function validateGetResearchCuratorRequest( const platform = searchParams.get("platform"); const id = searchParams.get("id"); - if (!platform || !id) { - return errorResponse("platform and id parameters are required", 400); + if (!platform) return errorResponse("platform parameter is required", 400); + if (!id) return errorResponse("id parameter is required", 400); + + if (!VALID_PLATFORMS.includes(platform as Platform)) { + return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); + } + + if (!/^\d+$/.test(id)) { + return errorResponse("id must be a numeric Chartmetric curator ID (e.g. 2 for Spotify)", 400); } - return { accountId: authResult.accountId, platform, id }; + return { accountId: authResult.accountId, platform: platform as Platform, id }; } From fda5592256f327ac23f27b23dff2bd975bb16cf4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 16 Apr 2026 11:02:43 -0500 Subject: [PATCH 24/28] fix(research/track): add artist disambiguation + better match ranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #366 review (https://github.com/recoupable/api/pull/366#issuecomment-4256348805): every common track query ("God's Plan", "Hotline Bling", "Flowers", "Sicko Mode", "Thriller") returned the wrong track because the resolver called Chartmetric /search?type=tracks&limit=1 and took tracks[0] with no ranking and no artist filter. Changes: - Raise the upstream limit to 25 (was 1) so real candidates make it into the pool. - New pickBestTrackMatch(tracks, q, artist?) — pure ranking function: 1. If artist supplied, drop candidates whose artist_names don't contain artist (case-insensitive substring). 2. Within the remaining pool, prefer a track whose name equals q (trimmed, case-insensitive) — fixes "God's Plan" vs "God's". 3. Otherwise fall back to the first remaining track. - Validator accepts an optional `artist` query param. - Handler returns 404 with both q and artist in the message when the artist filter yields nothing (matches the OpenAPI 404 added in recoupable/docs#138). Docs PR (now merged): recoupable/docs#138 tightens the /research/track spec to advertise the new `artist` param, the 404 response, and the actual response shape (artists[], albums[], etc. — not the wrong album_name/artist_names the spec claimed). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/getResearchTrackHandler.test.ts | 79 ++++++++++++- .../__tests__/pickBestTrackMatch.test.ts | 110 ++++++++++++++++++ .../validateGetResearchTrackRequest.test.ts | 16 ++- lib/research/getResearchTrackHandler.ts | 26 +++-- lib/research/pickBestTrackMatch.ts | 46 ++++++++ .../validateGetResearchTrackRequest.ts | 8 +- 6 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 lib/research/__tests__/pickBestTrackMatch.test.ts create mode 100644 lib/research/pickBestTrackMatch.ts diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts index 4c498887e..c8bcff474 100644 --- a/lib/research/__tests__/getResearchTrackHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -23,6 +23,7 @@ describe("getResearchTrackHandler", () => { vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ accountId: "test-id", q: "Hotline Bling", + artist: undefined, }); }); @@ -89,11 +90,87 @@ describe("getResearchTrackHandler", () => { expect(handleResearch).toHaveBeenNthCalledWith(1, { accountId: "test-id", path: "/search", - query: { q: "Hotline Bling", type: "tracks", limit: "1" }, + query: { q: "Hotline Bling", type: "tracks", limit: "25" }, }); expect(handleResearch).toHaveBeenNthCalledWith(2, { accountId: "test-id", path: "/track/999", }); }); + + it("prefers an exact-name match over the first candidate when disambiguating ambiguous titles", async () => { + vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ + accountId: "test-id", + q: "God's Plan", + artist: undefined, + }); + vi.mocked(handleResearch) + .mockResolvedValueOnce({ + data: { + tracks: [ + { id: 111, name: "God's", artist_names: ["bobby fox"] }, + { id: 222, name: "God's Plan", artist_names: ["Drake"] }, + ], + }, + }) + .mockResolvedValueOnce({ data: { id: 222, name: "God's Plan" } }); + const req = new NextRequest("http://localhost/api/research/track?q=God%27s+Plan"); + const res = await getResearchTrackHandler(req); + + expect(res.status).toBe(200); + expect(handleResearch).toHaveBeenNthCalledWith(2, { + accountId: "test-id", + path: "/track/222", + }); + }); + + it("filters candidates by artist_names (case-insensitive) when artist is provided", async () => { + vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ + accountId: "test-id", + q: "Flowers", + artist: "miley cyrus", + }); + vi.mocked(handleResearch) + .mockResolvedValueOnce({ + data: { + tracks: [ + { id: 1, name: "Flowers", artist_names: ["Yuda"] }, + { id: 2, name: "Flowers", artist_names: ["Miley Cyrus"] }, + ], + }, + }) + .mockResolvedValueOnce({ data: { id: 2, name: "Flowers" } }); + const req = new NextRequest("http://localhost/api/research/track?q=Flowers&artist=miley+cyrus"); + const res = await getResearchTrackHandler(req); + + expect(res.status).toBe(200); + expect(handleResearch).toHaveBeenNthCalledWith(2, { + accountId: "test-id", + path: "/track/2", + }); + }); + + it("returns 404 with the artist in the message when the artist filter yields no match", async () => { + vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ + accountId: "test-id", + q: "Flowers", + artist: "Miley Cyrus", + }); + vi.mocked(handleResearch).mockResolvedValueOnce({ + data: { + tracks: [ + { id: 1, name: "Flowers", artist_names: ["Yuda"] }, + { id: 2, name: "Flowers", artist_names: ["AylexMusic"] }, + ], + }, + }); + const req = new NextRequest("http://localhost/api/research/track?q=Flowers&artist=Miley+Cyrus"); + const res = await getResearchTrackHandler(req); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toContain("Flowers"); + expect(body.error).toContain("Miley Cyrus"); + expect(handleResearch).toHaveBeenCalledTimes(1); + }); }); diff --git a/lib/research/__tests__/pickBestTrackMatch.test.ts b/lib/research/__tests__/pickBestTrackMatch.test.ts new file mode 100644 index 000000000..557493d66 --- /dev/null +++ b/lib/research/__tests__/pickBestTrackMatch.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { pickBestTrackMatch, type SearchTrack } from "../pickBestTrackMatch"; + +const drakeHotline: SearchTrack = { + id: 1, + name: "Hotline Bling", + artist_names: ["Drake"], +}; +const blingCover: SearchTrack = { + id: 2, + name: "Bling", + artist_names: ["T703R"], +}; +const drakeGodsPlan: SearchTrack = { + id: 3, + name: "God's Plan", + artist_names: ["Drake"], +}; +const godsOnly: SearchTrack = { + id: 4, + name: "God's", + artist_names: ["bobby fox"], +}; + +describe("pickBestTrackMatch", () => { + describe("without artist filter", () => { + it("returns the first track when no exact-name match is found", () => { + const result = pickBestTrackMatch({ + tracks: [blingCover, drakeHotline], + q: "Something unrelated", + }); + expect(result).toBe(blingCover); + }); + + it("prefers an exact case-insensitive name match over the first entry", () => { + const result = pickBestTrackMatch({ + tracks: [godsOnly, drakeGodsPlan], + q: "god's plan", + }); + expect(result).toBe(drakeGodsPlan); + }); + + it("returns undefined when the pool is empty", () => { + expect(pickBestTrackMatch({ tracks: [], q: "Flowers" })).toBeUndefined(); + }); + }); + + describe("with artist filter", () => { + it("keeps only tracks whose artist_names contain the artist (case-insensitive)", () => { + const result = pickBestTrackMatch({ + tracks: [blingCover, drakeHotline], + q: "Hotline Bling", + artist: "drake", + }); + expect(result).toBe(drakeHotline); + }); + + it("returns undefined when no track matches the artist filter", () => { + const result = pickBestTrackMatch({ + tracks: [blingCover, godsOnly], + q: "God's Plan", + artist: "Drake", + }); + expect(result).toBeUndefined(); + }); + + it("still applies exact-name-match ranking within the artist-filtered pool", () => { + const drakeGodsRemix: SearchTrack = { + id: 5, + name: "God's Plan (Remix)", + artist_names: ["Drake", "Someone"], + }; + const result = pickBestTrackMatch({ + tracks: [drakeGodsRemix, drakeGodsPlan], + q: "God's Plan", + artist: "Drake", + }); + expect(result).toBe(drakeGodsPlan); + }); + + it("matches artist names via substring (e.g. 'Horizon' matches 'Bring Me The Horizon')", () => { + const bmth: SearchTrack = { + id: 6, + name: "Can You Feel My Heart", + artist_names: ["Bring Me The Horizon"], + }; + const result = pickBestTrackMatch({ + tracks: [bmth], + q: "Can You Feel My Heart", + artist: "horizon", + }); + expect(result).toBe(bmth); + }); + + it("handles tracks missing artist_names by filtering them out", () => { + const noArtists: SearchTrack = { id: 7, name: "Flowers" }; + const miley: SearchTrack = { + id: 8, + name: "Flowers", + artist_names: ["Miley Cyrus"], + }; + const result = pickBestTrackMatch({ + tracks: [noArtists, miley], + q: "Flowers", + artist: "Miley Cyrus", + }); + expect(result).toBe(miley); + }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchTrackRequest.test.ts b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts index 081d3230a..7993f4833 100644 --- a/lib/research/__tests__/validateGetResearchTrackRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts @@ -44,6 +44,20 @@ describe("validateGetResearchTrackRequest", () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); const res = await validateGetResearchTrackRequest(req); - expect(res).toEqual({ accountId: "acc_1", q: "Hotline Bling" }); + expect(res).toEqual({ accountId: "acc_1", q: "Hotline Bling", artist: undefined }); + }); + + it("passes through an optional artist query param", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling&artist=Drake"); + const res = await validateGetResearchTrackRequest(req); + expect(res).toEqual({ accountId: "acc_1", q: "Hotline Bling", artist: "Drake" }); + }); + + it("returns undefined artist when the query param is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track?q=Flowers"); + const res = await validateGetResearchTrackRequest(req); + expect((res as { artist?: string }).artist).toBeUndefined(); }); }); diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts index 6ff51c1eb..910851c09 100644 --- a/lib/research/getResearchTrackHandler.ts +++ b/lib/research/getResearchTrackHandler.ts @@ -2,15 +2,17 @@ import { type NextRequest, NextResponse } from "next/server"; import { errorResponse } from "@/lib/networking/errorResponse"; import { successResponse } from "@/lib/networking/successResponse"; import { handleResearch } from "@/lib/research/handleResearch"; +import { pickBestTrackMatch, type SearchTrack } from "@/lib/research/pickBestTrackMatch"; import { validateGetResearchTrackRequest } from "@/lib/research/validateGetResearchTrackRequest"; /** * GET /api/research/track * - * Searches Chartmetric for a track by name, then fetches full details for the - * top match. + * Searches Chartmetric for a track by name, picks the best match (optionally + * disambiguated by `artist`), then fetches full details for that track. * - * @param request - must include `q` query param + * @param request - must include `q` query param; optional `artist` param + * disambiguates against candidate tracks' `artist_names`. * @returns JSON track details or error */ export async function getResearchTrackHandler(request: NextRequest): Promise { @@ -21,22 +23,28 @@ export async function getResearchTrackHandler(request: NextRequest): Promise })?.tracks; - if (!tracks || tracks.length === 0) { - return errorResponse(`No track found matching "${validated.q}"`, 404); + const tracks = (searchResult.data as { tracks?: SearchTrack[] })?.tracks ?? []; + const match = pickBestTrackMatch({ + tracks, + q: validated.q, + artist: validated.artist, + }); + + if (!match) { + const suffix = validated.artist ? ` by "${validated.artist}"` : ""; + return errorResponse(`No track found matching "${validated.q}"${suffix}`, 404); } - const trackId = tracks[0].id; const result = await handleResearch({ accountId: validated.accountId, - path: `/track/${trackId}`, + path: `/track/${match.id}`, }); if ("error" in result) return errorResponse("Failed to fetch track details", result.status); diff --git a/lib/research/pickBestTrackMatch.ts b/lib/research/pickBestTrackMatch.ts new file mode 100644 index 000000000..30aa8c500 --- /dev/null +++ b/lib/research/pickBestTrackMatch.ts @@ -0,0 +1,46 @@ +export type SearchTrack = { + id: number | string; + name?: string; + artist_names?: string[]; +}; + +type PickBestTrackMatchParams = { + tracks: SearchTrack[]; + q: string; + artist?: string; +}; + +/** + * Picks the best-matching track from a Chartmetric search result set. + * + * Ranking: + * 1. If `artist` is supplied, drop candidates whose `artist_names` don't contain + * `artist` (case-insensitive substring match). Returns `undefined` if none + * remain — the handler turns that into a 404. + * 2. Within the remaining pool, prefer a track whose `name` equals `q` (case + * insensitive, trimmed) — this disambiguates `"God's Plan"` from a track + * merely named `"God's"`. + * 3. Otherwise return the first remaining track. + * + * @returns The best matching track, or `undefined` if the pool is empty after + * filtering. + */ +export function pickBestTrackMatch({ + tracks, + q, + artist, +}: PickBestTrackMatchParams): SearchTrack | undefined { + let pool = tracks; + + if (artist) { + const needle = artist.trim().toLowerCase(); + pool = tracks.filter(t => (t.artist_names ?? []).some(n => n.toLowerCase().includes(needle))); + if (pool.length === 0) return undefined; + } + + if (pool.length === 0) return undefined; + + const qLower = q.trim().toLowerCase(); + const exact = pool.find(t => t.name?.trim().toLowerCase() === qLower); + return exact ?? pool[0]; +} diff --git a/lib/research/validateGetResearchTrackRequest.ts b/lib/research/validateGetResearchTrackRequest.ts index 3c053431a..7beaac3a2 100644 --- a/lib/research/validateGetResearchTrackRequest.ts +++ b/lib/research/validateGetResearchTrackRequest.ts @@ -5,10 +5,13 @@ import { errorResponse } from "@/lib/networking/errorResponse"; export type ValidatedGetResearchTrackRequest = { accountId: string; q: string; + artist: string | undefined; }; /** - * Validates `GET /api/research/track` — auth + required `q` query param. + * Validates `GET /api/research/track` — auth + required `q` query param, with + * an optional `artist` param that's used downstream to disambiguate matches + * against the search result's `artist_names`. * * @param request - The incoming HTTP request. */ @@ -22,5 +25,6 @@ export async function validateGetResearchTrackRequest( const q = searchParams.get("q"); if (!q) return errorResponse("q parameter is required", 400); - return { accountId: authResult.accountId, q }; + const artist = searchParams.get("artist") ?? undefined; + return { accountId: authResult.accountId, q, artist }; } From a28deb252a7072d746fcd8b1a976b900eddd2296 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 16 Apr 2026 11:18:06 -0500 Subject: [PATCH 25/28] feat(research/search): pass through beta, platforms, offset to Chartmetric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chartmetric's default search engine returns exactly 1 result for every track query (verified against preview: q=Hotline Bling → only T703R 'Bling', q=Flowers → only a Yuda cover, q=Drake&type=tracks → 1 track). Its docs describe beta=true as "improved beta search engine for higher relevance and accuracy", and platforms[] is beta-only. This adds diagnostic passthrough so we can observe the beta response shape live, and unblocks follow-up work on /api/research/track. All three params are optional and only forwarded when explicitly set, so default behavior is unchanged. Handler also falls back to `suggestions` (beta response shape) when no artists/tracks/albums array is populated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../getResearchSearchHandler.test.ts | 56 +++++++++++++++++++ .../validateGetResearchSearchRequest.test.ts | 21 ++++++- lib/research/getResearchSearchHandler.ts | 20 ++++++- .../validateGetResearchSearchRequest.ts | 11 +++- 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts index 1980c17be..3ca418724 100644 --- a/lib/research/__tests__/getResearchSearchHandler.test.ts +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -25,6 +25,9 @@ describe("getResearchSearchHandler", () => { q: "Drake", type: "artists", limit: "10", + beta: undefined, + platforms: undefined, + offset: undefined, }); }); @@ -59,4 +62,57 @@ describe("getResearchSearchHandler", () => { expect(body.status).toBe("success"); expect(body.results).toEqual([{ name: "Drake", id: 3380 }]); }); + + it("forwards only the defaulted params to Chartmetric when no optional params are provided", async () => { + vi.mocked(handleResearch).mockResolvedValue({ data: { artists: [] } }); + const req = new NextRequest("http://localhost/api/research/search?q=Drake"); + await getResearchSearchHandler(req); + + expect(handleResearch).toHaveBeenCalledWith({ + accountId: "test-id", + path: "/search", + query: { q: "Drake", type: "artists", limit: "10" }, + }); + }); + + it("forwards beta, platforms, and offset to Chartmetric when provided", async () => { + vi.mocked(validateGetResearchSearchRequest).mockResolvedValue({ + accountId: "test-id", + q: "Hotline Bling", + type: "tracks", + limit: "25", + beta: "true", + platforms: "cm,spotify", + offset: "5", + }); + vi.mocked(handleResearch).mockResolvedValue({ data: { tracks: [] } }); + const req = new NextRequest( + "http://localhost/api/research/search?q=Hotline+Bling&type=tracks&beta=true&platforms=cm,spotify&offset=5&limit=25", + ); + await getResearchSearchHandler(req); + + expect(handleResearch).toHaveBeenCalledWith({ + accountId: "test-id", + path: "/search", + query: { + q: "Hotline Bling", + type: "tracks", + limit: "25", + beta: "true", + platforms: "cm,spotify", + offset: "5", + }, + }); + }); + + it("returns suggestions when the beta engine returns a suggestions array", async () => { + vi.mocked(handleResearch).mockResolvedValue({ + data: { suggestions: [{ name: "Drake", target: "artists", match_strength: 0.99 }] }, + }); + const req = new NextRequest("http://localhost/api/research/search?q=Drake&beta=true"); + const res = await getResearchSearchHandler(req); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.results[0]).toMatchObject({ name: "Drake", target: "artists" }); + }); }); diff --git a/lib/research/__tests__/validateGetResearchSearchRequest.test.ts b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts index 2dea11ec0..db8b8b4cc 100644 --- a/lib/research/__tests__/validateGetResearchSearchRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts @@ -40,11 +40,19 @@ describe("validateGetResearchSearchRequest", () => { expect(body.error).toBe("q parameter is required"); }); - it("fills defaults for type and limit", async () => { + it("fills defaults for type and limit, omits optional params", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest("http://localhost/api/research/search?q=Drake"); const res = await validateGetResearchSearchRequest(req); - expect(res).toEqual({ accountId: "acc_1", q: "Drake", type: "artists", limit: "10" }); + expect(res).toEqual({ + accountId: "acc_1", + q: "Drake", + type: "artists", + limit: "10", + beta: undefined, + platforms: undefined, + offset: undefined, + }); }); it("preserves explicit type and limit", async () => { @@ -55,4 +63,13 @@ describe("validateGetResearchSearchRequest", () => { const res = await validateGetResearchSearchRequest(req); expect(res).toMatchObject({ type: "tracks", limit: "25" }); }); + + it("passes through beta, platforms, and offset when provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/search?q=Drake&beta=true&platforms=cm,spotify&offset=5", + ); + const res = await validateGetResearchSearchRequest(req); + expect(res).toMatchObject({ beta: "true", platforms: "cm,spotify", offset: "5" }); + }); }); diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts index 4f9cc7804..ab9ada2bb 100644 --- a/lib/research/getResearchSearchHandler.ts +++ b/lib/research/getResearchSearchHandler.ts @@ -17,16 +17,30 @@ export async function getResearchSearchHandler(request: NextRequest): Promise = { + q: validated.q, + type: validated.type, + limit: validated.limit, + }; + if (validated.beta !== undefined) query.beta = validated.beta; + if (validated.platforms !== undefined) query.platforms = validated.platforms; + if (validated.offset !== undefined) query.offset = validated.offset; + const result = await handleResearch({ accountId: validated.accountId, path: "/search", - query: { q: validated.q, type: validated.type, limit: validated.limit }, + query, }); if ("error" in result) return errorResponse("Search failed", result.status); - const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; - const results = data?.artists || data?.tracks || data?.albums || []; + const data = result.data as { + artists?: unknown[]; + tracks?: unknown[]; + albums?: unknown[]; + suggestions?: unknown[]; + }; + const results = data?.artists || data?.tracks || data?.albums || data?.suggestions || []; return successResponse({ results }); } catch (error) { console.error("[ERROR] getResearchSearchHandler:", error); diff --git a/lib/research/validateGetResearchSearchRequest.ts b/lib/research/validateGetResearchSearchRequest.ts index 8a10a74d1..dfd75bf5a 100644 --- a/lib/research/validateGetResearchSearchRequest.ts +++ b/lib/research/validateGetResearchSearchRequest.ts @@ -7,11 +7,17 @@ export type ValidatedGetResearchSearchRequest = { q: string; type: string; limit: string; + beta: string | undefined; + platforms: string | undefined; + offset: string | undefined; }; /** * Validates `GET /api/research/search` — auth + required `q` query param, with - * defaults for `type` ("artists") and `limit` ("10"). + * defaults for `type` ("artists") and `limit` ("10"). Also accepts the optional + * Chartmetric pass-throughs: `beta` (enables the improved search engine), + * `platforms` (comma-separated string, beta-only per Chartmetric docs), and + * `offset` (numeric string for paging). * * @param request - The incoming HTTP request. */ @@ -30,5 +36,8 @@ export async function validateGetResearchSearchRequest( q, type: searchParams.get("type") || "artists", limit: searchParams.get("limit") || "10", + beta: searchParams.get("beta") ?? undefined, + platforms: searchParams.get("platforms") ?? undefined, + offset: searchParams.get("offset") ?? undefined, }; } From c787d0502b74ef90030ba64af55975b8e0cc64f0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 16 Apr 2026 11:38:08 -0500 Subject: [PATCH 26/28] refactor(research): /track and /playlist become pure ID proxies (KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original handlers chained /search → /track/:id (and /search → /playlist/:platform/:id on non-numeric ids). The compound behavior produced wrong results silently, unpredictable credit charges, and asymmetric error paths that we kept having to patch (see recoupable/api#366 review). We're reverting both endpoints to single-call proxies. Discovery is the caller's job via GET /api/research — which already exposes beta=true for high-relevance search ranking (a28deb25). /api/research/track - Takes a numeric Chartmetric track `id`, single upstream call to /track/:id - Drops the `q` search param, the `artist` disambiguation param, and the client-side pickBestTrackMatch ranking helper (not needed — search is the caller's job) - 400 when `id` is missing or non-numeric /api/research/playlist - Unchanged public shape: still takes platform + id - Drops the non-numeric-id name-search fallback inside the handler - Single upstream call to /playlist/:platform/:id for all inputs Tests: 146 research tests green. `pickBestTrackMatch` and its 8 unit tests deleted (never shipped; only lived on this branch in fda55922). Follow-up docs PR will align /research/track's OpenAPI contract with the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/research/track/route.ts | 5 +- .../getResearchPlaylistHandler.test.ts | 85 +++++++++++ .../__tests__/getResearchTrackHandler.test.ts | 135 ++---------------- .../__tests__/pickBestTrackMatch.test.ts | 110 -------------- .../validateGetResearchTrackRequest.test.ts | 27 ++-- lib/research/getResearchPlaylistHandler.ts | 38 +---- lib/research/getResearchTrackHandler.ts | 33 +---- lib/research/pickBestTrackMatch.ts | 46 ------ .../validateGetResearchPlaylistRequest.ts | 5 +- .../validateGetResearchTrackRequest.ts | 18 +-- 10 files changed, 135 insertions(+), 367 deletions(-) create mode 100644 lib/research/__tests__/getResearchPlaylistHandler.test.ts delete mode 100644 lib/research/__tests__/pickBestTrackMatch.test.ts delete mode 100644 lib/research/pickBestTrackMatch.ts diff --git a/app/api/research/track/route.ts b/app/api/research/track/route.ts index e8fce5ed7..174df92fe 100644 --- a/app/api/research/track/route.ts +++ b/app/api/research/track/route.ts @@ -12,9 +12,10 @@ export async function OPTIONS() { } /** - * GET /api/research/track — Search for a track by name and return full details. Requires `?q=` query param. + * GET /api/research/track — Full Chartmetric track details by numeric `id`. + * Discovery (search by name) is the caller's job via `GET /api/research`. * - * @param request - must include `q` query param + * @param request - must include numeric `id` query param * @returns JSON track details or error */ export async function GET(request: NextRequest) { diff --git a/lib/research/__tests__/getResearchPlaylistHandler.test.ts b/lib/research/__tests__/getResearchPlaylistHandler.test.ts new file mode 100644 index 000000000..f75716a87 --- /dev/null +++ b/lib/research/__tests__/getResearchPlaylistHandler.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchPlaylistHandler } from "../getResearchPlaylistHandler"; +import { validateGetResearchPlaylistRequest } from "../validateGetResearchPlaylistRequest"; +import { handleResearch } from "../handleResearch"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetResearchPlaylistRequest", () => ({ + validateGetResearchPlaylistRequest: vi.fn(), +})); + +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), +})); + +describe("getResearchPlaylistHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateGetResearchPlaylistRequest).mockResolvedValue({ + accountId: "test-id", + platform: "spotify", + id: "37i9dQZF1DXcBWIGoYBM5M", + }); + }); + + it("passes through validator error response", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateGetResearchPlaylistRequest).mockResolvedValue(err); + const req = new NextRequest("http://localhost/api/research/playlist"); + const res = await getResearchPlaylistHandler(req); + expect(res).toBe(err); + }); + + it("fetches /playlist/:platform/:id and returns 200 with the data", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ + data: { id: "37i9dQZF1DXcBWIGoYBM5M", name: "RapCaviar" }, + }); + const req = new NextRequest( + "http://localhost/api/research/playlist?platform=spotify&id=37i9dQZF1DXcBWIGoYBM5M", + ); + const res = await getResearchPlaylistHandler(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.name).toBe("RapCaviar"); + expect(handleResearch).toHaveBeenCalledTimes(1); + expect(handleResearch).toHaveBeenCalledWith({ + accountId: "test-id", + path: "/playlist/spotify/37i9dQZF1DXcBWIGoYBM5M", + }); + }); + + it("propagates upstream error status with a 'Playlist lookup failed' message", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ + error: "Request failed with status 404", + status: 404, + }); + const req = new NextRequest( + "http://localhost/api/research/playlist?platform=spotify&id=unknown", + ); + const res = await getResearchPlaylistHandler(req); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Playlist lookup failed"); + }); + + it("does NOT perform a fallback search when id is non-numeric", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ + data: { id: "abc", name: "Something" }, + }); + const req = new NextRequest("http://localhost/api/research/playlist?platform=spotify&id=abc"); + await getResearchPlaylistHandler(req); + + expect(handleResearch).toHaveBeenCalledTimes(1); + expect(handleResearch).toHaveBeenCalledWith({ + accountId: "test-id", + path: "/playlist/spotify/37i9dQZF1DXcBWIGoYBM5M", + }); + }); +}); diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts index c8bcff474..c427e00ff 100644 --- a/lib/research/__tests__/getResearchTrackHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -22,8 +22,7 @@ describe("getResearchTrackHandler", () => { vi.clearAllMocks(); vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ accountId: "test-id", - q: "Hotline Bling", - artist: undefined, + id: "15194376", }); }); @@ -35,142 +34,34 @@ describe("getResearchTrackHandler", () => { expect(res).toBe(err); }); - it("returns 'Track search failed' when search errors", async () => { + it("fetches /track/:id from Chartmetric and returns 200 with the data", async () => { vi.mocked(handleResearch).mockResolvedValueOnce({ - error: "Request failed with status 502", - status: 502, + data: { id: 15194376, name: "Hotline Bling", artists: [{ id: 1, name: "Drake" }] }, }); - const req = new NextRequest("http://localhost/api/research/track?q=foo"); + const req = new NextRequest("http://localhost/api/research/track?id=15194376"); const res = await getResearchTrackHandler(req); - expect(res.status).toBe(502); - const body = await res.json(); - expect(body.error).toBe("Track search failed"); - }); - - it("returns 404 when no track matches", async () => { - vi.mocked(handleResearch).mockResolvedValueOnce({ data: { tracks: [] } }); - const req = new NextRequest("http://localhost/api/research/track?q=nothing"); - const res = await getResearchTrackHandler(req); - expect(res.status).toBe(404); - const body = await res.json(); - expect(body.error).toContain("No track found"); - }); - - it("returns 'Failed to fetch track details' on detail error", async () => { - vi.mocked(handleResearch) - .mockResolvedValueOnce({ data: { tracks: [{ id: 12345 }] } }) - .mockResolvedValueOnce({ error: "Request failed with status 503", status: 503 }); - const req = new NextRequest("http://localhost/api/research/track?q=foo"); - const res = await getResearchTrackHandler(req); - expect(res.status).toBe(503); - const body = await res.json(); - expect(body.error).toBe("Failed to fetch track details"); - }); - it("returns 200 with track data on success", async () => { - vi.mocked(handleResearch) - .mockResolvedValueOnce({ data: { tracks: [{ id: 12345 }] } }) - .mockResolvedValueOnce({ data: { name: "Hotline Bling", artist: "Drake", id: 12345 } }); - const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); - const res = await getResearchTrackHandler(req); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe("success"); expect(body.name).toBe("Hotline Bling"); - }); - - it("invokes handleResearch twice on successful lookup so credits are deducted for both hops", async () => { - vi.mocked(handleResearch) - .mockResolvedValueOnce({ data: { tracks: [{ id: 999 }] } }) - .mockResolvedValueOnce({ data: { id: 999 } }); - const req = new NextRequest("http://localhost/api/research/track?q=foo"); - await getResearchTrackHandler(req); - - expect(handleResearch).toHaveBeenCalledTimes(2); - expect(handleResearch).toHaveBeenNthCalledWith(1, { - accountId: "test-id", - path: "/search", - query: { q: "Hotline Bling", type: "tracks", limit: "25" }, - }); - expect(handleResearch).toHaveBeenNthCalledWith(2, { - accountId: "test-id", - path: "/track/999", - }); - }); - - it("prefers an exact-name match over the first candidate when disambiguating ambiguous titles", async () => { - vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ - accountId: "test-id", - q: "God's Plan", - artist: undefined, - }); - vi.mocked(handleResearch) - .mockResolvedValueOnce({ - data: { - tracks: [ - { id: 111, name: "God's", artist_names: ["bobby fox"] }, - { id: 222, name: "God's Plan", artist_names: ["Drake"] }, - ], - }, - }) - .mockResolvedValueOnce({ data: { id: 222, name: "God's Plan" } }); - const req = new NextRequest("http://localhost/api/research/track?q=God%27s+Plan"); - const res = await getResearchTrackHandler(req); - - expect(res.status).toBe(200); - expect(handleResearch).toHaveBeenNthCalledWith(2, { - accountId: "test-id", - path: "/track/222", - }); - }); - - it("filters candidates by artist_names (case-insensitive) when artist is provided", async () => { - vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ - accountId: "test-id", - q: "Flowers", - artist: "miley cyrus", - }); - vi.mocked(handleResearch) - .mockResolvedValueOnce({ - data: { - tracks: [ - { id: 1, name: "Flowers", artist_names: ["Yuda"] }, - { id: 2, name: "Flowers", artist_names: ["Miley Cyrus"] }, - ], - }, - }) - .mockResolvedValueOnce({ data: { id: 2, name: "Flowers" } }); - const req = new NextRequest("http://localhost/api/research/track?q=Flowers&artist=miley+cyrus"); - const res = await getResearchTrackHandler(req); - - expect(res.status).toBe(200); - expect(handleResearch).toHaveBeenNthCalledWith(2, { + expect(body.id).toBe(15194376); + expect(handleResearch).toHaveBeenCalledTimes(1); + expect(handleResearch).toHaveBeenCalledWith({ accountId: "test-id", - path: "/track/2", + path: "/track/15194376", }); }); - it("returns 404 with the artist in the message when the artist filter yields no match", async () => { - vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ - accountId: "test-id", - q: "Flowers", - artist: "Miley Cyrus", - }); + it("propagates upstream error status and message", async () => { vi.mocked(handleResearch).mockResolvedValueOnce({ - data: { - tracks: [ - { id: 1, name: "Flowers", artist_names: ["Yuda"] }, - { id: 2, name: "Flowers", artist_names: ["AylexMusic"] }, - ], - }, + error: "Request failed with status 404", + status: 404, }); - const req = new NextRequest("http://localhost/api/research/track?q=Flowers&artist=Miley+Cyrus"); + const req = new NextRequest("http://localhost/api/research/track?id=999"); const res = await getResearchTrackHandler(req); - expect(res.status).toBe(404); const body = await res.json(); - expect(body.error).toContain("Flowers"); - expect(body.error).toContain("Miley Cyrus"); - expect(handleResearch).toHaveBeenCalledTimes(1); + expect(body.error).toBe("Failed to fetch track details"); }); }); diff --git a/lib/research/__tests__/pickBestTrackMatch.test.ts b/lib/research/__tests__/pickBestTrackMatch.test.ts deleted file mode 100644 index 557493d66..000000000 --- a/lib/research/__tests__/pickBestTrackMatch.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { pickBestTrackMatch, type SearchTrack } from "../pickBestTrackMatch"; - -const drakeHotline: SearchTrack = { - id: 1, - name: "Hotline Bling", - artist_names: ["Drake"], -}; -const blingCover: SearchTrack = { - id: 2, - name: "Bling", - artist_names: ["T703R"], -}; -const drakeGodsPlan: SearchTrack = { - id: 3, - name: "God's Plan", - artist_names: ["Drake"], -}; -const godsOnly: SearchTrack = { - id: 4, - name: "God's", - artist_names: ["bobby fox"], -}; - -describe("pickBestTrackMatch", () => { - describe("without artist filter", () => { - it("returns the first track when no exact-name match is found", () => { - const result = pickBestTrackMatch({ - tracks: [blingCover, drakeHotline], - q: "Something unrelated", - }); - expect(result).toBe(blingCover); - }); - - it("prefers an exact case-insensitive name match over the first entry", () => { - const result = pickBestTrackMatch({ - tracks: [godsOnly, drakeGodsPlan], - q: "god's plan", - }); - expect(result).toBe(drakeGodsPlan); - }); - - it("returns undefined when the pool is empty", () => { - expect(pickBestTrackMatch({ tracks: [], q: "Flowers" })).toBeUndefined(); - }); - }); - - describe("with artist filter", () => { - it("keeps only tracks whose artist_names contain the artist (case-insensitive)", () => { - const result = pickBestTrackMatch({ - tracks: [blingCover, drakeHotline], - q: "Hotline Bling", - artist: "drake", - }); - expect(result).toBe(drakeHotline); - }); - - it("returns undefined when no track matches the artist filter", () => { - const result = pickBestTrackMatch({ - tracks: [blingCover, godsOnly], - q: "God's Plan", - artist: "Drake", - }); - expect(result).toBeUndefined(); - }); - - it("still applies exact-name-match ranking within the artist-filtered pool", () => { - const drakeGodsRemix: SearchTrack = { - id: 5, - name: "God's Plan (Remix)", - artist_names: ["Drake", "Someone"], - }; - const result = pickBestTrackMatch({ - tracks: [drakeGodsRemix, drakeGodsPlan], - q: "God's Plan", - artist: "Drake", - }); - expect(result).toBe(drakeGodsPlan); - }); - - it("matches artist names via substring (e.g. 'Horizon' matches 'Bring Me The Horizon')", () => { - const bmth: SearchTrack = { - id: 6, - name: "Can You Feel My Heart", - artist_names: ["Bring Me The Horizon"], - }; - const result = pickBestTrackMatch({ - tracks: [bmth], - q: "Can You Feel My Heart", - artist: "horizon", - }); - expect(result).toBe(bmth); - }); - - it("handles tracks missing artist_names by filtering them out", () => { - const noArtists: SearchTrack = { id: 7, name: "Flowers" }; - const miley: SearchTrack = { - id: 8, - name: "Flowers", - artist_names: ["Miley Cyrus"], - }; - const result = pickBestTrackMatch({ - tracks: [noArtists, miley], - q: "Flowers", - artist: "Miley Cyrus", - }); - expect(result).toBe(miley); - }); - }); -}); diff --git a/lib/research/__tests__/validateGetResearchTrackRequest.test.ts b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts index 7993f4833..f51ea81f1 100644 --- a/lib/research/__tests__/validateGetResearchTrackRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts @@ -26,38 +26,33 @@ describe("validateGetResearchTrackRequest", () => { it("returns the auth response when auth fails", async () => { const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(authErr); - const req = new NextRequest("http://localhost/api/research/track?q=foo"); + const req = new NextRequest("http://localhost/api/research/track?id=12345"); const res = await validateGetResearchTrackRequest(req); expect(res).toBe(authErr); }); - it("returns 400 when q is missing", async () => { + it("returns 400 when id is missing", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest("http://localhost/api/research/track"); const res = await validateGetResearchTrackRequest(req); expect((res as NextResponse).status).toBe(400); const body = await (res as NextResponse).json(); - expect(body.error).toBe("q parameter is required"); + expect(body.error).toBe("id parameter is required"); }); - it("returns the validated request", async () => { + it("returns 400 when id is not a positive integer", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); + const req = new NextRequest("http://localhost/api/research/track?id=abc"); const res = await validateGetResearchTrackRequest(req); - expect(res).toEqual({ accountId: "acc_1", q: "Hotline Bling", artist: undefined }); - }); - - it("passes through an optional artist query param", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling&artist=Drake"); - const res = await validateGetResearchTrackRequest(req); - expect(res).toEqual({ accountId: "acc_1", q: "Hotline Bling", artist: "Drake" }); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("id must be a positive integer"); }); - it("returns undefined artist when the query param is missing", async () => { + it("returns the validated request for a numeric id", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/track?q=Flowers"); + const req = new NextRequest("http://localhost/api/research/track?id=15194376"); const res = await validateGetResearchTrackRequest(req); - expect((res as { artist?: string }).artist).toBeUndefined(); + expect(res).toEqual({ accountId: "acc_1", id: "15194376" }); }); }); diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts index dd46cc77e..f729bd52e 100644 --- a/lib/research/getResearchPlaylistHandler.ts +++ b/lib/research/getResearchPlaylistHandler.ts @@ -7,8 +7,10 @@ import { validateGetResearchPlaylistRequest } from "@/lib/research/validateGetRe /** * GET /api/research/playlist * - * Looks up a playlist by platform + ID, falling back to a name search when the - * `id` is non-numeric. + * Returns full Chartmetric playlist details for the supplied `platform` and + * `id`. This endpoint is a thin proxy over Chartmetric's + * `/playlist/:platform/:id`; discovery (search by name) is the caller's job + * via `GET /api/research?type=playlists&beta=true`. * * @param request - query params: platform, id * @returns JSON playlist details or error @@ -18,37 +20,9 @@ export async function getResearchPlaylistHandler(request: NextRequest): Promise< const validated = await validateGetResearchPlaylistRequest(request); if (validated instanceof NextResponse) return validated; - const { accountId, platform, id } = validated; - let playlistId = id; - - if (!/^\d+$/.test(id)) { - const searchResult = await handleResearch({ - accountId, - path: "/search", - query: { q: id, type: "playlists", limit: "1" }, - }); - - if ("error" in searchResult) { - return errorResponse( - `Search failed with status ${searchResult.status}`, - searchResult.status, - ); - } - - const playlists = ( - searchResult.data as { playlists?: { [key: string]: Array<{ id: number }> } } - )?.playlists?.[platform]; - - if (!playlists || playlists.length === 0) { - return errorResponse(`No playlist found matching "${id}" on ${platform}`, 404); - } - - playlistId = String(playlists[0].id); - } - const result = await handleResearch({ - accountId, - path: `/playlist/${platform}/${playlistId}`, + accountId: validated.accountId, + path: `/playlist/${validated.platform}/${validated.id}`, }); if ("error" in result) return errorResponse("Playlist lookup failed", result.status); diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts index 910851c09..c098a0b0b 100644 --- a/lib/research/getResearchTrackHandler.ts +++ b/lib/research/getResearchTrackHandler.ts @@ -2,17 +2,16 @@ import { type NextRequest, NextResponse } from "next/server"; import { errorResponse } from "@/lib/networking/errorResponse"; import { successResponse } from "@/lib/networking/successResponse"; import { handleResearch } from "@/lib/research/handleResearch"; -import { pickBestTrackMatch, type SearchTrack } from "@/lib/research/pickBestTrackMatch"; import { validateGetResearchTrackRequest } from "@/lib/research/validateGetResearchTrackRequest"; /** * GET /api/research/track * - * Searches Chartmetric for a track by name, picks the best match (optionally - * disambiguated by `artist`), then fetches full details for that track. + * Returns full Chartmetric track details for the supplied `id`. This endpoint + * is a thin proxy over Chartmetric's `/track/:id`; discovery (search by name, + * filter by artist) is the caller's job via `GET /api/research?type=tracks&beta=true`. * - * @param request - must include `q` query param; optional `artist` param - * disambiguates against candidate tracks' `artist_names`. + * @param request - must include numeric `id` query param * @returns JSON track details or error */ export async function getResearchTrackHandler(request: NextRequest): Promise { @@ -20,31 +19,9 @@ export async function getResearchTrackHandler(request: NextRequest): Promise (t.artist_names ?? []).some(n => n.toLowerCase().includes(needle))); - if (pool.length === 0) return undefined; - } - - if (pool.length === 0) return undefined; - - const qLower = q.trim().toLowerCase(); - const exact = pool.find(t => t.name?.trim().toLowerCase() === qLower); - return exact ?? pool[0]; -} diff --git a/lib/research/validateGetResearchPlaylistRequest.ts b/lib/research/validateGetResearchPlaylistRequest.ts index 73856015f..7a673640b 100644 --- a/lib/research/validateGetResearchPlaylistRequest.ts +++ b/lib/research/validateGetResearchPlaylistRequest.ts @@ -12,8 +12,9 @@ export type ValidatedGetResearchPlaylistRequest = { /** * Validates `GET /api/research/playlist` — auth + required `platform` (one of - * spotify/applemusic/deezer/amazon/youtube) and `id` (numeric ID or playlist - * name; non-numeric values trigger a name-search fallback in the handler). + * spotify/applemusic/deezer/amazon/youtube) and `id` (the platform-native + * playlist ID; format varies by platform, e.g. Spotify base62, Apple/Deezer + * numeric). Discovery by name is the caller's job via `GET /api/research`. * * @param request - The incoming HTTP request. */ diff --git a/lib/research/validateGetResearchTrackRequest.ts b/lib/research/validateGetResearchTrackRequest.ts index 7beaac3a2..d31c14e8e 100644 --- a/lib/research/validateGetResearchTrackRequest.ts +++ b/lib/research/validateGetResearchTrackRequest.ts @@ -4,14 +4,14 @@ import { errorResponse } from "@/lib/networking/errorResponse"; export type ValidatedGetResearchTrackRequest = { accountId: string; - q: string; - artist: string | undefined; + id: string; }; /** - * Validates `GET /api/research/track` — auth + required `q` query param, with - * an optional `artist` param that's used downstream to disambiguate matches - * against the search result's `artist_names`. + * Validates `GET /api/research/track` — auth + required numeric `id` (the + * Chartmetric track ID). Discovery (search by name, filter by artist) is the + * caller's job via `GET /api/research?type=tracks&beta=true`; this endpoint + * is a thin detail-lookup proxy. * * @param request - The incoming HTTP request. */ @@ -22,9 +22,9 @@ export async function validateGetResearchTrackRequest( if (authResult instanceof NextResponse) return authResult; const { searchParams } = new URL(request.url); - const q = searchParams.get("q"); - if (!q) return errorResponse("q parameter is required", 400); + const id = searchParams.get("id"); + if (!id) return errorResponse("id parameter is required", 400); + if (!/^[1-9]\d*$/.test(id)) return errorResponse("id must be a positive integer", 400); - const artist = searchParams.get("artist") ?? undefined; - return { accountId: authResult.accountId, q, artist }; + return { accountId: authResult.accountId, id }; } From cb6152b24565802b700ba9353d2cf8d7dc31596d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 16 Apr 2026 11:59:19 -0500 Subject: [PATCH 27/28] fix(research): /albums id-proxy + /charts enum validation (KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the remaining two items from the PR #366 review comment. /api/research/albums - Replaces the composite name→artist_id→discography flow (same shape that was returning "DJ Mix" feeds for artist=Drake and duplicated "Elizabeth Taylor" albums for artist=Taylor Swift) with a thin /artist/:id/albums proxy. - New dedicated validator validateGetResearchAlbumsRequest.ts takes a numeric artist_id; drops the old name/UUID fuzzy-resolve path. - Discovery is the caller's job via GET /api/research?type=artists&beta=true. - Note: the shared validateArtistRequest + handleArtistResearch composites still power ~17 other artist-scoped endpoints (similar, metrics, cities, audience, career, etc.). Those carry the same risk class and should get the same treatment in follow-up PRs — out of scope here. /api/research/charts - Validator now enforces the documented enums at our layer: type ∈ {regional, viral} interval ∈ {daily, weekly} latest ∈ {true, false} - This converts opaque upstream 400s (e.g. type=top → Chartmetric 400) into specific 400s from us that name the valid values. Nothing is silently ignored: params not in the spec (date, artist) were never forwarded and now surface clearly. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/research/albums/route.ts | 5 +- .../getResearchAlbumsHandler.test.ts | 81 +++++++++++++++++++ .../validateGetResearchAlbumsRequest.test.ts | 58 +++++++++++++ .../validateGetResearchChartsRequest.test.ts | 31 +++++++ lib/research/getResearchAlbumsHandler.ts | 28 +++---- .../validateGetResearchAlbumsRequest.ts | 30 +++++++ .../validateGetResearchChartsRequest.ts | 29 ++++++- 7 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 lib/research/__tests__/getResearchAlbumsHandler.test.ts create mode 100644 lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts create mode 100644 lib/research/validateGetResearchAlbumsRequest.ts diff --git a/app/api/research/albums/route.ts b/app/api/research/albums/route.ts index a5c539d47..d42f6aac4 100644 --- a/app/api/research/albums/route.ts +++ b/app/api/research/albums/route.ts @@ -12,9 +12,10 @@ export async function OPTIONS() { } /** - * GET /api/research/albums — Artist album discography with release dates. Requires `?artist=` query param. + * GET /api/research/albums — Album discography for a Chartmetric artist id. + * Discovery by name is the caller's job via `GET /api/research`. * - * @param request - must include `artist` query param + * @param request - must include numeric `artist_id` query param * @returns JSON album list or error */ export async function GET(request: NextRequest) { diff --git a/lib/research/__tests__/getResearchAlbumsHandler.test.ts b/lib/research/__tests__/getResearchAlbumsHandler.test.ts new file mode 100644 index 000000000..929aaa62d --- /dev/null +++ b/lib/research/__tests__/getResearchAlbumsHandler.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchAlbumsHandler } from "../getResearchAlbumsHandler"; +import { validateGetResearchAlbumsRequest } from "../validateGetResearchAlbumsRequest"; +import { handleResearch } from "../handleResearch"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetResearchAlbumsRequest", () => ({ + validateGetResearchAlbumsRequest: vi.fn(), +})); + +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), +})); + +describe("getResearchAlbumsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateGetResearchAlbumsRequest).mockResolvedValue({ + accountId: "test-id", + artistId: "3380", + }); + }); + + it("passes through validator error response", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateGetResearchAlbumsRequest).mockResolvedValue(err); + const req = new NextRequest("http://localhost/api/research/albums"); + const res = await getResearchAlbumsHandler(req); + expect(res).toBe(err); + }); + + it("fetches /artist/:id/albums and returns 200 with albums array", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ + data: [ + { id: 1, name: "Scorpion" }, + { id: 2, name: "Views" }, + ], + }); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=3380"); + const res = await getResearchAlbumsHandler(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.albums).toEqual([ + { id: 1, name: "Scorpion" }, + { id: 2, name: "Views" }, + ]); + expect(handleResearch).toHaveBeenCalledWith({ + accountId: "test-id", + path: "/artist/3380/albums", + }); + }); + + it("returns an empty albums array when upstream returns a non-array", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ data: null }); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=3380"); + const res = await getResearchAlbumsHandler(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.albums).toEqual([]); + }); + + it("propagates upstream error status", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ + error: "Request failed with status 404", + status: 404, + }); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=999"); + const res = await getResearchAlbumsHandler(req); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch artist albums"); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts new file mode 100644 index 000000000..3230418fd --- /dev/null +++ b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetResearchAlbumsRequest } from "../validateGetResearchAlbumsRequest"; +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 okAuth = { + accountId: "acc_1", + orgId: null, + authToken: "tok", +} as ReturnType extends Promise + ? Exclude + : never; + +describe("validateGetResearchAlbumsRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the auth response when auth fails", async () => { + const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=3380"); + const res = await validateGetResearchAlbumsRequest(req); + expect(res).toBe(authErr); + }); + + it("returns 400 when artist_id is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/albums"); + const res = await validateGetResearchAlbumsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("artist_id parameter is required"); + }); + + it("returns 400 when artist_id is not a positive integer", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=Drake"); + const res = await validateGetResearchAlbumsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("artist_id must be a positive integer"); + }); + + it("returns the validated request", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=3380"); + const res = await validateGetResearchAlbumsRequest(req); + expect(res).toEqual({ accountId: "acc_1", artistId: "3380" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchChartsRequest.test.ts b/lib/research/__tests__/validateGetResearchChartsRequest.test.ts index aeaa9348b..7b10ee3a3 100644 --- a/lib/research/__tests__/validateGetResearchChartsRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchChartsRequest.test.ts @@ -80,4 +80,35 @@ describe("validateGetResearchChartsRequest", () => { latest: "false", }); }); + + it("returns 400 for an unknown type (not regional or viral)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&type=top"); + const res = await validateGetResearchChartsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("type must be one of"); + expect(body.error).toContain("regional"); + expect(body.error).toContain("viral"); + }); + + it("returns 400 for an unknown interval (not daily or weekly)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/charts?platform=spotify&interval=monthly", + ); + const res = await validateGetResearchChartsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("interval must be one of"); + }); + + it("returns 400 for a latest value that isn't a boolean string", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&latest=yes"); + const res = await validateGetResearchChartsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("latest must be"); + }); }); diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 8f0376c91..82e7b834b 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -1,30 +1,30 @@ -import { type NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { successResponse } from "@/lib/networking/successResponse"; +import { type NextRequest, NextResponse } from "next/server"; import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { handleResearch } from "@/lib/research/handleResearch"; +import { validateGetResearchAlbumsRequest } from "@/lib/research/validateGetResearchAlbumsRequest"; /** * GET /api/research/albums * - * Returns the album discography for the given artist. - * Requires `artist` query param. + * Returns the album discography for the given Chartmetric `artist_id`. Thin + * proxy over Chartmetric's `/artist/:id/albums`; discovery by name is the + * caller's job via `GET /api/research?type=artists&beta=true`. * - * @param request - The incoming HTTP request. - * @returns The JSON response. + * @param request - must include numeric `artist_id` query param + * @returns JSON album list or error */ export async function getResearchAlbumsHandler(request: NextRequest): Promise { try { - const validated = await validateArtistRequest(request); + const validated = await validateGetResearchAlbumsRequest(request); if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - ...validated, - path: cmId => `/artist/${cmId}/albums`, + const result = await handleResearch({ + accountId: validated.accountId, + path: `/artist/${validated.artistId}/albums`, }); - if ("error" in result) return errorResponse(result.error, result.status); + if ("error" in result) return errorResponse("Failed to fetch artist albums", result.status); return successResponse({ albums: Array.isArray(result.data) ? result.data : [] }); } catch (error) { console.error("[ERROR] getResearchAlbumsHandler:", error); diff --git a/lib/research/validateGetResearchAlbumsRequest.ts b/lib/research/validateGetResearchAlbumsRequest.ts new file mode 100644 index 000000000..acb7bfe59 --- /dev/null +++ b/lib/research/validateGetResearchAlbumsRequest.ts @@ -0,0 +1,30 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +export type ValidatedGetResearchAlbumsRequest = { + accountId: string; + artistId: string; +}; + +/** + * Validates `GET /api/research/albums` — auth + required numeric `artist_id` + * (the Chartmetric artist ID). Discovery (search by name) is the caller's job + * via `GET /api/research?type=artists&beta=true`; this endpoint is a thin + * discography-lookup proxy. + * + * @param request - The incoming HTTP request. + */ +export async function validateGetResearchAlbumsRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const artistId = new URL(request.url).searchParams.get("artist_id"); + if (!artistId) return errorResponse("artist_id parameter is required", 400); + if (!/^[1-9]\d*$/.test(artistId)) + return errorResponse("artist_id must be a positive integer", 400); + + return { accountId: authResult.accountId, artistId }; +} diff --git a/lib/research/validateGetResearchChartsRequest.ts b/lib/research/validateGetResearchChartsRequest.ts index 391e052ff..8c777193d 100644 --- a/lib/research/validateGetResearchChartsRequest.ts +++ b/lib/research/validateGetResearchChartsRequest.ts @@ -2,6 +2,10 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { errorResponse } from "@/lib/networking/errorResponse"; +const VALID_TYPES = ["regional", "viral"] as const; +const VALID_INTERVALS = ["daily", "weekly"] as const; +const VALID_LATEST = ["true", "false"] as const; + export type ValidatedGetResearchChartsRequest = { accountId: string; platform: string; @@ -14,7 +18,9 @@ export type ValidatedGetResearchChartsRequest = { /** * Validates `GET /api/research/charts` — auth + `platform` (required, lowercase * alpha) + defaults for `country` ("US"), `interval` ("daily"), `type` - * ("regional"), and `latest` ("true"). + * ("regional"), and `latest` ("true"). `interval`, `type`, and `latest` are + * rejected at this layer if they aren't in the documented enum — this turns + * an opaque upstream Chartmetric 400 into a specific 400 from us. * * @param request - The incoming HTTP request. */ @@ -29,12 +35,27 @@ export async function validateGetResearchChartsRequest( if (!platform) return errorResponse("platform parameter is required", 400); if (!/^[a-z]+$/.test(platform)) return errorResponse("Invalid platform parameter", 400); + const type = searchParams.get("type") || "regional"; + if (!(VALID_TYPES as readonly string[]).includes(type)) { + return errorResponse(`type must be one of: ${VALID_TYPES.join(", ")}`, 400); + } + + const interval = searchParams.get("interval") || "daily"; + if (!(VALID_INTERVALS as readonly string[]).includes(interval)) { + return errorResponse(`interval must be one of: ${VALID_INTERVALS.join(", ")}`, 400); + } + + const latest = searchParams.get("latest") ?? "true"; + if (!(VALID_LATEST as readonly string[]).includes(latest)) { + return errorResponse(`latest must be "true" or "false"`, 400); + } + return { accountId: authResult.accountId, platform, country: searchParams.get("country") || "US", - interval: searchParams.get("interval") || "daily", - type: searchParams.get("type") || "regional", - latest: searchParams.get("latest") ?? "true", + interval, + type, + latest, }; } From 969f5f8a5381ddc1fb123bb13771919a96c1f577 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 16 Apr 2026 14:33:35 -0500 Subject: [PATCH 28/28] fix(research/albums): default isPrimary=true to exclude features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Chartmetric docs, /artist/:id/albums has an isPrimary query param that defaults to false, meaning the response includes every album where the artist is *featured* — not just their own discography. That's why canonical ids were returning wrong data on the preview: artist_id=3380 (Drake) → "nila: welcome to the aprtment (DJ Mix)" etc. artist_id=3963 (Ariana Grande) → "Wicked: For Good" soundtrack etc. Our handler now defaults to isPrimary=true so "albums" means the artist's own discography by default. Callers who actually want features and compilations can opt in with is_primary=false. Also exposes limit and offset for pagination (Chartmetric defaults to limit=100, offset=0). Sort params (sortColumn, sortOrderDesc) not exposed — upstream defaults (release_date desc) are sensible; can add later if a caller asks. Validator rejects: - is_primary values other than "true"/"false" (400) - non-positive-integer limit (400) - negative offset (400) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../getResearchAlbumsHandler.test.ts | 27 ++++++++- .../validateGetResearchAlbumsRequest.test.ts | 57 ++++++++++++++++++- lib/research/getResearchAlbumsHandler.ts | 15 ++++- .../validateGetResearchAlbumsRequest.ts | 32 +++++++++-- 4 files changed, 120 insertions(+), 11 deletions(-) diff --git a/lib/research/__tests__/getResearchAlbumsHandler.test.ts b/lib/research/__tests__/getResearchAlbumsHandler.test.ts index 929aaa62d..f7e2bd266 100644 --- a/lib/research/__tests__/getResearchAlbumsHandler.test.ts +++ b/lib/research/__tests__/getResearchAlbumsHandler.test.ts @@ -23,6 +23,9 @@ describe("getResearchAlbumsHandler", () => { vi.mocked(validateGetResearchAlbumsRequest).mockResolvedValue({ accountId: "test-id", artistId: "3380", + isPrimary: "true", + limit: undefined, + offset: undefined, }); }); @@ -34,7 +37,7 @@ describe("getResearchAlbumsHandler", () => { expect(res).toBe(err); }); - it("fetches /artist/:id/albums and returns 200 with albums array", async () => { + it("fetches /artist/:id/albums with isPrimary=true and returns 200 with albums array", async () => { vi.mocked(handleResearch).mockResolvedValueOnce({ data: [ { id: 1, name: "Scorpion" }, @@ -54,6 +57,28 @@ describe("getResearchAlbumsHandler", () => { expect(handleResearch).toHaveBeenCalledWith({ accountId: "test-id", path: "/artist/3380/albums", + query: { isPrimary: "true" }, + }); + }); + + it("forwards is_primary=false, limit, and offset to Chartmetric when provided", async () => { + vi.mocked(validateGetResearchAlbumsRequest).mockResolvedValue({ + accountId: "test-id", + artistId: "3380", + isPrimary: "false", + limit: "25", + offset: "50", + }); + vi.mocked(handleResearch).mockResolvedValueOnce({ data: [] }); + const req = new NextRequest( + "http://localhost/api/research/albums?artist_id=3380&is_primary=false&limit=25&offset=50", + ); + await getResearchAlbumsHandler(req); + + expect(handleResearch).toHaveBeenCalledWith({ + accountId: "test-id", + path: "/artist/3380/albums", + query: { isPrimary: "false", limit: "25", offset: "50" }, }); }); diff --git a/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts index 3230418fd..767337e81 100644 --- a/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts @@ -49,10 +49,63 @@ describe("validateGetResearchAlbumsRequest", () => { expect(body.error).toBe("artist_id must be a positive integer"); }); - it("returns the validated request", async () => { + it("defaults to is_primary=true and omits pagination when not supplied", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest("http://localhost/api/research/albums?artist_id=3380"); const res = await validateGetResearchAlbumsRequest(req); - expect(res).toEqual({ accountId: "acc_1", artistId: "3380" }); + expect(res).toEqual({ + accountId: "acc_1", + artistId: "3380", + isPrimary: "true", + limit: undefined, + offset: undefined, + }); + }); + + it("accepts is_primary=false to include features/compilations", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/albums?artist_id=3380&is_primary=false", + ); + const res = await validateGetResearchAlbumsRequest(req); + expect(res).toMatchObject({ isPrimary: "false" }); + }); + + it("returns 400 when is_primary isn't true or false", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/albums?artist_id=3380&is_primary=yes", + ); + const res = await validateGetResearchAlbumsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("is_primary must be"); + }); + + it("passes through limit and offset when provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/albums?artist_id=3380&limit=25&offset=50", + ); + const res = await validateGetResearchAlbumsRequest(req); + expect(res).toMatchObject({ limit: "25", offset: "50" }); + }); + + it("returns 400 when limit is not a positive integer", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=3380&limit=abc"); + const res = await validateGetResearchAlbumsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("limit must be a positive integer"); + }); + + it("returns 400 when offset is negative", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=3380&offset=-1"); + const res = await validateGetResearchAlbumsRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("offset must be a non-negative integer"); }); }); diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 82e7b834b..c8e9208d3 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -8,10 +8,14 @@ import { validateGetResearchAlbumsRequest } from "@/lib/research/validateGetRese * GET /api/research/albums * * Returns the album discography for the given Chartmetric `artist_id`. Thin - * proxy over Chartmetric's `/artist/:id/albums`; discovery by name is the - * caller's job via `GET /api/research?type=artists&beta=true`. + * proxy over Chartmetric's `/artist/:id/albums`. By default `isPrimary=true` + * is sent upstream so only albums where the artist is a main artist are + * returned — callers can opt into feature appearances and DJ compilations + * with `is_primary=false`. Discovery by name is the caller's job via + * `GET /api/research?type=artists&beta=true`. * - * @param request - must include numeric `artist_id` query param + * @param request - must include numeric `artist_id`; optional `is_primary`, + * `limit`, `offset` * @returns JSON album list or error */ export async function getResearchAlbumsHandler(request: NextRequest): Promise { @@ -19,9 +23,14 @@ export async function getResearchAlbumsHandler(request: NextRequest): Promise = { isPrimary: validated.isPrimary }; + if (validated.limit !== undefined) query.limit = validated.limit; + if (validated.offset !== undefined) query.offset = validated.offset; + const result = await handleResearch({ accountId: validated.accountId, path: `/artist/${validated.artistId}/albums`, + query, }); if ("error" in result) return errorResponse("Failed to fetch artist albums", result.status); diff --git a/lib/research/validateGetResearchAlbumsRequest.ts b/lib/research/validateGetResearchAlbumsRequest.ts index acb7bfe59..5df0769df 100644 --- a/lib/research/validateGetResearchAlbumsRequest.ts +++ b/lib/research/validateGetResearchAlbumsRequest.ts @@ -5,13 +5,19 @@ import { errorResponse } from "@/lib/networking/errorResponse"; export type ValidatedGetResearchAlbumsRequest = { accountId: string; artistId: string; + isPrimary: string; + limit: string | undefined; + offset: string | undefined; }; +const VALID_BOOLEAN = ["true", "false"] as const; + /** * Validates `GET /api/research/albums` — auth + required numeric `artist_id` - * (the Chartmetric artist ID). Discovery (search by name) is the caller's job - * via `GET /api/research?type=artists&beta=true`; this endpoint is a thin - * discography-lookup proxy. + * (Chartmetric artist ID). Optional `is_primary` (defaults to `"true"`) maps + * to Chartmetric's `isPrimary` filter, which when true returns only albums + * where the artist is a main artist — excluding DJ compilations, soundtracks, + * and feature appearances. Optional `limit` and `offset` for pagination. * * @param request - The incoming HTTP request. */ @@ -21,10 +27,26 @@ export async function validateGetResearchAlbumsRequest( const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; - const artistId = new URL(request.url).searchParams.get("artist_id"); + const { searchParams } = new URL(request.url); + const artistId = searchParams.get("artist_id"); if (!artistId) return errorResponse("artist_id parameter is required", 400); if (!/^[1-9]\d*$/.test(artistId)) return errorResponse("artist_id must be a positive integer", 400); - return { accountId: authResult.accountId, artistId }; + const isPrimary = searchParams.get("is_primary") ?? "true"; + if (!(VALID_BOOLEAN as readonly string[]).includes(isPrimary)) { + return errorResponse(`is_primary must be "true" or "false"`, 400); + } + + const limit = searchParams.get("limit") ?? undefined; + if (limit !== undefined && !/^[1-9]\d*$/.test(limit)) { + return errorResponse("limit must be a positive integer", 400); + } + + const offset = searchParams.get("offset") ?? undefined; + if (offset !== undefined && !/^(0|[1-9]\d*)$/.test(offset)) { + return errorResponse("offset must be a non-negative integer", 400); + } + + return { accountId: authResult.accountId, artistId, isPrimary, limit, offset }; }