From 8dcaa24550a78c675e2cecd263034438d7c81fd1 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:23:59 -0400 Subject: [PATCH] feat: add 20 research API endpoints (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add 30 research API endpoints, 28 MCP tools, Zod validation, and tests 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 * feat: add GET /api/research/track/playlists endpoint 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 * fix: use Spotify-powered track search for reliable q= resolution 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 * 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 * 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 * fix: use Chartmetric /track/:type/:id/get-ids for track resolution Maps ISRC → chartmetric_ids via the correct endpoint path. Falls back to Spotify track ID if ISRC lookup fails. Platform-agnostic. Made-with: Cursor * style: fix formatting in research track playlists files Made-with: Cursor * fix(lint): remove unused getCorsHeaders import Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(review): SRP on chartmetric token cache; drop MCP research tools 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) * 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) * refactor: rename getArtistResearch → handleArtistResearch (no functional change) 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) * refactor: consolidate JSON response helpers (DRY) 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) * refactor: align research handler naming + shape with codebase conventions - 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) * refactor(kiss): spread validated into handleArtistResearch call 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) * refactor: align charts + discover handlers with validator/helper conventions - 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) * refactor: port final 4 research handlers; delete handleResearchRequest - 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) * refactor(kiss): rename handleResearchProxy → handleResearch, proxyToChartmetric → fetchChartmetric 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) * refactor: move fetchChartmetric to lib/chartmetric Co-Authored-By: Claude Opus 4.6 (1M context) * 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) * 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) * refactor: charge credits for each Chartmetric hop in track+playlist lookups 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) * 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) * 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) * fix(research/track): add artist disambiguation + better match ranking 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) * feat(research/search): pass through beta, platforms, offset to Chartmetric 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) * refactor(research): /track and /playlist become pure ID proxies (KISS) 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) * fix(research): /albums id-proxy + /charts enum validation (KISS) 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) * fix(research/albums): default isPrimary=true to exclude features 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) --------- Co-authored-by: Recoupable Co-authored-by: Sweets Sweetman Co-authored-by: Claude Opus 4.6 (1M context) --- app/api/research/albums/route.ts | 23 +++ 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/playlists/route.ts | 22 +++ app/api/research/track/route.ts | 23 +++ 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__/fetchChartmetric.test.ts | 93 ++++++++++++ .../__tests__/getChartmetricToken.test.ts | 78 ++++++++++ .../__tests__/resetTokenCache.test.ts | 39 +++++ lib/chartmetric/chartmetricBase.ts | 2 + lib/chartmetric/chartmetricTokenCache.ts | 14 ++ lib/chartmetric/fetchChartmetric.ts | 56 +++++++ lib/chartmetric/getChartmetricToken.ts | 50 +++++++ lib/chartmetric/resetTokenCache.ts | 12 ++ lib/exa/searchPeople.ts | 66 +++++++++ .../__tests__/errorResponse.test.ts | 8 +- .../__tests__/successResponse.test.ts | 19 +++ lib/networking/errorResponse.ts | 9 +- lib/networking/successResponse.ts | 15 ++ lib/parallel/enrichEntity.ts | 95 ++++++++++++ lib/parallel/extractUrl.ts | 60 ++++++++ .../getResearchAlbumsHandler.test.ts | 106 ++++++++++++++ .../getResearchChartsHandler.test.ts | 83 +++++++++++ .../getResearchDiscoverHandler.test.ts | 138 ++++++++++++++++++ .../getResearchLookupHandler.test.ts | 86 +++++++++++ .../getResearchMetricsHandler.test.ts | 81 ++++++++++ .../getResearchPlaylistHandler.test.ts | 85 +++++++++++ .../getResearchSearchHandler.test.ts | 118 +++++++++++++++ .../getResearchSimilarHandler.test.ts | 87 +++++++++++ .../__tests__/getResearchTrackHandler.test.ts | 67 +++++++++ .../getResearchTrackPlaylistsHandler.test.ts | 126 ++++++++++++++++ .../__tests__/handleArtistResearch.test.ts | 100 +++++++++++++ lib/research/__tests__/handleResearch.test.ts | 73 +++++++++ .../__tests__/postResearchWebHandler.test.ts | 79 ++++++++++ lib/research/__tests__/resolveArtist.test.ts | 81 ++++++++++ lib/research/__tests__/resolveTrack.test.ts | 113 ++++++++++++++ .../__tests__/validateArtistRequest.test.ts | 45 ++++++ .../validateGetResearchAlbumsRequest.test.ts | 111 ++++++++++++++ .../validateGetResearchChartsRequest.test.ts | 114 +++++++++++++++ .../validateGetResearchCuratorRequest.test.ts | 96 ++++++++++++ ...validateGetResearchDiscoverRequest.test.ts | 75 ++++++++++ ...alidateGetResearchFestivalsRequest.test.ts | 37 +++++ .../validateGetResearchGenresRequest.test.ts | 37 +++++ .../validateGetResearchLookupRequest.test.ts | 65 +++++++++ .../validateGetResearchMetricsRequest.test.ts | 59 ++++++++ ...validateGetResearchPlaylistRequest.test.ts | 71 +++++++++ ...alidateGetResearchPlaylistsRequest.test.ts | 52 +++++++ .../validateGetResearchRadioRequest.test.ts | 38 +++++ .../validateGetResearchSearchRequest.test.ts | 75 ++++++++++ .../validateGetResearchSimilarRequest.test.ts | 60 ++++++++ ...teGetResearchTrackPlaylistsRequest.test.ts | 101 +++++++++++++ .../validateGetResearchTrackRequest.test.ts | 58 ++++++++ .../validatePostResearchEnrichRequest.test.ts | 62 ++++++++ ...validatePostResearchExtractRequest.test.ts | 63 ++++++++ .../validatePostResearchPeopleRequest.test.ts | 50 +++++++ .../validatePostResearchWebRequest.test.ts | 51 +++++++ lib/research/getResearchAlbumsHandler.ts | 42 ++++++ lib/research/getResearchAudienceHandler.ts | 42 ++++++ lib/research/getResearchCareerHandler.ts | 33 +++++ lib/research/getResearchChartsHandler.ts | 44 ++++++ lib/research/getResearchCitiesHandler.ts | 45 ++++++ lib/research/getResearchCuratorHandler.ts | 38 +++++ lib/research/getResearchDiscoverHandler.ts | 49 +++++++ lib/research/getResearchFestivalsHandler.ts | 32 ++++ lib/research/getResearchGenresHandler.ts | 31 ++++ lib/research/getResearchInsightsHandler.ts | 34 +++++ .../getResearchInstagramPostsHandler.ts | 41 ++++++ lib/research/getResearchLookupHandler.ts | 37 +++++ lib/research/getResearchMetricsHandler.ts | 40 +++++ lib/research/getResearchMilestonesHandler.ts | 35 +++++ lib/research/getResearchPlaylistHandler.ts | 40 +++++ lib/research/getResearchPlaylistsHandler.ts | 63 ++++++++ lib/research/getResearchProfileHandler.ts | 38 +++++ lib/research/getResearchRadioHandler.ts | 31 ++++ lib/research/getResearchRankHandler.ts | 34 +++++ lib/research/getResearchSearchHandler.ts | 49 +++++++ lib/research/getResearchSimilarHandler.ts | 49 +++++++ lib/research/getResearchTrackHandler.ts | 39 +++++ .../getResearchTrackPlaylistsHandler.ts | 46 ++++++ lib/research/getResearchTracksHandler.ts | 33 +++++ lib/research/getResearchUrlsHandler.ts | 39 +++++ lib/research/getResearchVenuesHandler.ts | 32 ++++ lib/research/handleArtistResearch.ts | 44 ++++++ lib/research/handleResearch.ts | 38 +++++ lib/research/postResearchDeepHandler.ts | 63 ++++++++ lib/research/postResearchEnrichHandler.ts | 53 +++++++ lib/research/postResearchExtractHandler.ts | 40 +++++ lib/research/postResearchPeopleHandler.ts | 35 +++++ lib/research/postResearchWebHandler.ts | 42 ++++++ lib/research/resolveArtist.ts | 53 +++++++ lib/research/resolveTrack.ts | 80 ++++++++++ lib/research/validateArtistRequest.ts | 22 +++ .../validateGetResearchAlbumsRequest.ts | 52 +++++++ .../validateGetResearchChartsRequest.ts | 61 ++++++++ .../validateGetResearchCuratorRequest.ts | 42 ++++++ .../validateGetResearchDiscoverRequest.ts | 54 +++++++ .../validateGetResearchFestivalsRequest.ts | 20 +++ .../validateGetResearchGenresRequest.ts | 20 +++ .../validateGetResearchLookupRequest.ts | 32 ++++ .../validateGetResearchMetricsRequest.ts | 46 ++++++ .../validateGetResearchPlaylistRequest.ts | 39 +++++ .../validateGetResearchPlaylistsRequest.ts | 42 ++++++ .../validateGetResearchRadioRequest.ts | 20 +++ .../validateGetResearchSearchRequest.ts | 43 ++++++ .../validateGetResearchSimilarRequest.ts | 52 +++++++ ...alidateGetResearchTrackPlaylistsRequest.ts | 100 +++++++++++++ .../validateGetResearchTrackRequest.ts | 30 ++++ .../validatePostResearchEnrichRequest.ts | 36 +++++ .../validatePostResearchExtractRequest.ts | 36 +++++ .../validatePostResearchPeopleRequest.ts | 34 +++++ .../validatePostResearchWebRequest.ts | 36 +++++ 136 files changed, 6333 insertions(+), 11 deletions(-) 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/playlists/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__/fetchChartmetric.test.ts create mode 100644 lib/chartmetric/__tests__/getChartmetricToken.test.ts create mode 100644 lib/chartmetric/__tests__/resetTokenCache.test.ts create mode 100644 lib/chartmetric/chartmetricBase.ts create mode 100644 lib/chartmetric/chartmetricTokenCache.ts create mode 100644 lib/chartmetric/fetchChartmetric.ts create mode 100644 lib/chartmetric/getChartmetricToken.ts create mode 100644 lib/chartmetric/resetTokenCache.ts create mode 100644 lib/exa/searchPeople.ts create mode 100644 lib/networking/__tests__/successResponse.test.ts create mode 100644 lib/networking/successResponse.ts create mode 100644 lib/parallel/enrichEntity.ts create mode 100644 lib/parallel/extractUrl.ts create mode 100644 lib/research/__tests__/getResearchAlbumsHandler.test.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__/getResearchPlaylistHandler.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__/getResearchTrackPlaylistsHandler.test.ts create mode 100644 lib/research/__tests__/handleArtistResearch.test.ts create mode 100644 lib/research/__tests__/handleResearch.test.ts create mode 100644 lib/research/__tests__/postResearchWebHandler.test.ts create mode 100644 lib/research/__tests__/resolveArtist.test.ts create mode 100644 lib/research/__tests__/resolveTrack.test.ts create mode 100644 lib/research/__tests__/validateArtistRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchChartsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchCuratorRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchDiscoverRequest.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__/validateGetResearchLookupRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchMetricsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchPlaylistsRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchRadioRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchSearchRequest.test.ts create mode 100644 lib/research/__tests__/validateGetResearchSimilarRequest.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/__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/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/getResearchTrackPlaylistsHandler.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/handleResearch.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/resolveArtist.ts create mode 100644 lib/research/resolveTrack.ts create mode 100644 lib/research/validateArtistRequest.ts create mode 100644 lib/research/validateGetResearchAlbumsRequest.ts create mode 100644 lib/research/validateGetResearchChartsRequest.ts create mode 100644 lib/research/validateGetResearchCuratorRequest.ts create mode 100644 lib/research/validateGetResearchDiscoverRequest.ts create mode 100644 lib/research/validateGetResearchFestivalsRequest.ts create mode 100644 lib/research/validateGetResearchGenresRequest.ts create mode 100644 lib/research/validateGetResearchLookupRequest.ts create mode 100644 lib/research/validateGetResearchMetricsRequest.ts create mode 100644 lib/research/validateGetResearchPlaylistRequest.ts create mode 100644 lib/research/validateGetResearchPlaylistsRequest.ts create mode 100644 lib/research/validateGetResearchRadioRequest.ts create mode 100644 lib/research/validateGetResearchSearchRequest.ts create mode 100644 lib/research/validateGetResearchSimilarRequest.ts create mode 100644 lib/research/validateGetResearchTrackPlaylistsRequest.ts create mode 100644 lib/research/validateGetResearchTrackRequest.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/app/api/research/albums/route.ts b/app/api/research/albums/route.ts new file mode 100644 index 000000000..d42f6aac4 --- /dev/null +++ b/app/api/research/albums/route.ts @@ -0,0 +1,23 @@ +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 — Album discography for a Chartmetric artist id. + * Discovery by name is the caller's job via `GET /api/research`. + * + * @param request - must include numeric `artist_id` 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/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/app/api/research/track/route.ts b/app/api/research/track/route.ts new file mode 100644 index 000000000..174df92fe --- /dev/null +++ b/app/api/research/track/route.ts @@ -0,0 +1,23 @@ +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 — Full Chartmetric track details by numeric `id`. + * Discovery (search by name) is the caller's job via `GET /api/research`. + * + * @param request - must include numeric `id` 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__/fetchChartmetric.test.ts b/lib/chartmetric/__tests__/fetchChartmetric.test.ts new file mode 100644 index 000000000..06bdb8571 --- /dev/null +++ b/lib/chartmetric/__tests__/fetchChartmetric.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; + +vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ + getChartmetricToken: vi.fn().mockResolvedValue("mock-token"), +})); + +const mockFetch = vi.fn(); + +describe("fetchChartmetric", () => { + 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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts new file mode 100644 index 000000000..bcc7a88af --- /dev/null +++ b/lib/chartmetric/__tests__/getChartmetricToken.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getChartmetricToken } from "../getChartmetricToken"; +import { resetTokenCache } from "../resetTokenCache"; + +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/__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/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/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/fetchChartmetric.ts b/lib/chartmetric/fetchChartmetric.ts new file mode 100644 index 000000000..ec04abcd1 --- /dev/null +++ b/lib/chartmetric/fetchChartmetric.ts @@ -0,0 +1,56 @@ +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; +import { CHARTMETRIC_BASE } from "@/lib/chartmetric/chartmetricBase"; + +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 fetchChartmetric( + 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/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts new file mode 100644 index 000000000..41dc958a0 --- /dev/null +++ b/lib/chartmetric/getChartmetricToken.ts @@ -0,0 +1,50 @@ +import { chartmetricTokenCache } from "./chartmetricTokenCache"; +import { CHARTMETRIC_BASE } from "./chartmetricBase"; + +/** + * 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 (chartmetricTokenCache.token && Date.now() < chartmetricTokenCache.expiresAt) { + return chartmetricTokenCache.token; + } + + const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; + + if (!refreshToken) { + throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); + } + + const response = await fetch(`${CHARTMETRIC_BASE}/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"); + } + + 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/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/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__/successResponse.test.ts b/lib/networking/__tests__/successResponse.test.ts new file mode 100644 index 000000000..20b931c77 --- /dev/null +++ b/lib/networking/__tests__/successResponse.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +import { successResponse } from "../successResponse"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); + +describe("successResponse", () => { + it("returns 200 with { status: 'success', ...body } and CORS headers", async () => { + const res = successResponse({ 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"] }); + }); +}); 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/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/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__/getResearchAlbumsHandler.test.ts b/lib/research/__tests__/getResearchAlbumsHandler.test.ts new file mode 100644 index 000000000..f7e2bd266 --- /dev/null +++ b/lib/research/__tests__/getResearchAlbumsHandler.test.ts @@ -0,0 +1,106 @@ +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", + isPrimary: "true", + limit: undefined, + offset: undefined, + }); + }); + + 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 with isPrimary=true 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", + 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" }, + }); + }); + + 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__/getResearchChartsHandler.test.ts b/lib/research/__tests__/getResearchChartsHandler.test.ts new file mode 100644 index 000000000..dd1b5fb2f --- /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 { 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("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: 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(fetchChartmetric).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(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(fetchChartmetric).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(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 new file mode 100644 index 000000000..030d7bc16 --- /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 { 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("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: 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(fetchChartmetric).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(fetchChartmetric).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(fetchChartmetric).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(fetchChartmetric).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..99b898e39 --- /dev/null +++ b/lib/research/__tests__/getResearchLookupHandler.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchLookupHandler } from "../getResearchLookupHandler"; +import { validateGetResearchLookupRequest } from "../validateGetResearchLookupRequest"; +import { handleResearch } from "../handleResearch"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetResearchLookupRequest", () => ({ + validateGetResearchLookupRequest: vi.fn(), +})); + +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), +})); + +describe("getResearchLookupHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateGetResearchLookupRequest).mockResolvedValue({ + accountId: "test-id", + spotifyId: "3TVXtAsR1Inumwj472S9r4", + }); + }); + + 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).toBe(err); + }); + + 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(502); + const body = await res.json(); + expect(body.error).toBe("Lookup failed"); + }); + + 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(handleResearch).mockResolvedValue({ data: arrayData }); + + 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.data).toEqual(arrayData); + expect(body).not.toHaveProperty("0"); + }); + + it("spreads object responses normally", async () => { + const objectData = { id: 3380, spotify_id: "3TVXtAsR1Inumwj472S9r4" }; + vi.mocked(handleResearch).mockResolvedValue({ data: objectData }); + + 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..80d0127bd --- /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/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: 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__/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__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts new file mode 100644 index 000000000..3ca418724 --- /dev/null +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchSearchHandler } from "../getResearchSearchHandler"; +import { validateGetResearchSearchRequest } from "../validateGetResearchSearchRequest"; +import { handleResearch } from "../handleResearch"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetResearchSearchRequest", () => ({ + validateGetResearchSearchRequest: 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", + beta: undefined, + platforms: undefined, + offset: undefined, + }); + }); + + 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).toBe(err); + }); + + 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/search?q=Drake"); + const res = await getResearchSearchHandler(req); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error).toBe("Search failed"); + }); + + it("returns 200 with results on success", async () => { + vi.mocked(handleResearch).mockResolvedValue({ + data: { artists: [{ name: "Drake", id: 3380 }] }, + }); + 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(); + 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__/getResearchSimilarHandler.test.ts b/lib/research/__tests__/getResearchSimilarHandler.test.ts new file mode 100644 index 000000000..25de06558 --- /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 { 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("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); + +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: 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(fetchChartmetric).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(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(fetchChartmetric).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(fetchChartmetric).mock.calls[0][0]; + expect(calledPath).toContain("by-configurations"); + }); + + it("passes default medium values for config params when none provided", async () => { + vi.mocked(fetchChartmetric).mockResolvedValue({ + data: [], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); + await getResearchSimilarHandler(req); + + const calledParams = vi.mocked(fetchChartmetric).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..c427e00ff --- /dev/null +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchTrackHandler } from "../getResearchTrackHandler"; +import { validateGetResearchTrackRequest } from "../validateGetResearchTrackRequest"; +import { handleResearch } from "../handleResearch"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetResearchTrackRequest", () => ({ + validateGetResearchTrackRequest: vi.fn(), +})); + +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), +})); + +describe("getResearchTrackHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateGetResearchTrackRequest).mockResolvedValue({ + accountId: "test-id", + id: "15194376", + }); + }); + + 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); + }); + + it("fetches /track/:id from Chartmetric and returns 200 with the data", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ + data: { id: 15194376, name: "Hotline Bling", artists: [{ id: 1, name: "Drake" }] }, + }); + const req = new NextRequest("http://localhost/api/research/track?id=15194376"); + 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"); + expect(body.id).toBe(15194376); + expect(handleResearch).toHaveBeenCalledTimes(1); + expect(handleResearch).toHaveBeenCalledWith({ + accountId: "test-id", + path: "/track/15194376", + }); + }); + + it("propagates upstream error status and message", async () => { + vi.mocked(handleResearch).mockResolvedValueOnce({ + error: "Request failed with status 404", + status: 404, + }); + 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).toBe("Failed to fetch track details"); + }); +}); diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts new file mode 100644 index 000000000..e676f5237 --- /dev/null +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; +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("../validateGetResearchTrackPlaylistsRequest", () => ({ + validateGetResearchTrackPlaylistsRequest: vi.fn(), +})); + +vi.mock("../handleResearch", () => ({ + handleResearch: vi.fn(), +})); + +vi.mock("../resolveTrack", () => ({ + resolveTrack: 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("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).toBe(err); + }); + + it("returns 200 with placements when given a track id", async () => { + vi.mocked(handleResearch).mockResolvedValue({ + data: [ + { + playlist: { + name: "Chill Vibes", + image_url: "https://i.scdn.co/image/abc", + editorial: true, + }, + track: { name: "God's Plan", cm_track: 18220712 }, + }, + ], + }); + 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(); + 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(validateGetResearchTrackPlaylistsRequest).mockResolvedValue({ + ...baseValidated, + id: null, + q: "God's Plan", + artist: "Drake", + }); + vi.mocked(resolveTrack).mockResolvedValue({ id: "18220712" }); + vi.mocked(handleResearch).mockResolvedValue({ + data: [{ playlist: { name: "Today's Top Hits" }, track: { name: "God's Plan" } }], + }); + + const req = new NextRequest( + "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.placements).toHaveLength(1); + expect(vi.mocked(resolveTrack)).toHaveBeenCalledWith("God's Plan", "Drake", "test-id"); + }); + + it("returns 404 when track name search finds nothing", async () => { + vi.mocked(validateGetResearchTrackPlaylistsRequest).mockResolvedValue({ + ...baseValidated, + id: null, + q: "nonexistent", + }); + vi.mocked(resolveTrack).mockResolvedValue({ + error: 'No track found matching "nonexistent"', + }); + 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(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__/handleArtistResearch.test.ts b/lib/research/__tests__/handleArtistResearch.test.ts new file mode 100644 index 000000000..60e91482f --- /dev/null +++ b/lib/research/__tests__/handleArtistResearch.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { handleArtistResearch } from "../handleArtistResearch"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +vi.mock("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), +})); +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("handleArtistResearch", () => { + 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 handleArtistResearch({ + artist: "Unknown", + accountId: "acc_1", + path: id => `/artist/${id}/albums`, + }); + + expect(result).toEqual({ error: "Artist not found", status: 404 }); + 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(fetchChartmetric).mockResolvedValue({ + status: 200, + data: [{ name: "a" }], + } as never); + vi.mocked(deductCredits).mockResolvedValue(undefined as never); + + const result = await handleArtistResearch({ + artist: "Drake", + accountId: "acc_1", + path: id => `/artist/${id}/albums`, + }); + + 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 fetchChartmetric", async () => { + vi.mocked(resolveArtist).mockResolvedValue({ id: 7 } as never); + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + + await handleArtistResearch({ + artist: "X", + accountId: "acc_1", + path: id => `/artist/${id}/playlists`, + query: { limit: "10", platform: "spotify" }, + }); + + expect(fetchChartmetric).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(fetchChartmetric).mockResolvedValue({ status: 502, data: null } as never); + + const result = await handleArtistResearch({ + 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(fetchChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(deductCredits).mockRejectedValue(new Error("DB down")); + + const result = await handleArtistResearch({ + artist: "X", + accountId: "acc_1", + path: () => "/x", + }); + + expect(result).toEqual({ data: "ok" }); + }); +}); diff --git a/lib/research/__tests__/handleResearch.test.ts b/lib/research/__tests__/handleResearch.test.ts new file mode 100644 index 000000000..78eec0937 --- /dev/null +++ b/lib/research/__tests__/handleResearch.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { handleResearch } from "../handleResearch"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), +})); +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("handleResearch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns { data } on 200 and deducts the default 5 credits", async () => { + vi.mocked(fetchChartmetric).mockResolvedValue({ + status: 200, + data: [{ id: 1 }], + } as never); + vi.mocked(deductCredits).mockResolvedValue(undefined as never); + + const result = await handleResearch({ + accountId: "acc_1", + path: "/charts/spotify", + query: { 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(fetchChartmetric).mockResolvedValue({ status: 502, data: null } as never); + + const result = await handleResearch({ + 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(fetchChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(deductCredits).mockRejectedValue(new Error("DB down")); + + const result = await handleResearch({ + accountId: "acc_1", + path: "/x", + }); + + expect(result).toEqual({ data: "ok" }); + }); + + it("respects the credits override", async () => { + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + vi.mocked(deductCredits).mockResolvedValue(undefined as never); + + await handleResearch({ + accountId: "acc_1", + path: "/x", + credits: 12, + }); + + expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 12 }); + }); +}); diff --git a/lib/research/__tests__/postResearchWebHandler.test.ts b/lib/research/__tests__/postResearchWebHandler.test.ts new file mode 100644 index 000000000..2dbbde672 --- /dev/null +++ b/lib/research/__tests__/postResearchWebHandler.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { postResearchWebHandler } from "../postResearchWebHandler"; +import { validatePostResearchWebRequest } from "../validatePostResearchWebRequest"; +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("../validatePostResearchWebRequest", () => ({ + validatePostResearchWebRequest: 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 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).toBe(err); + }); + + 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", + 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(validatePostResearchWebRequest).mockResolvedValue({ + accountId: "test-id", + query: "latest music trends", + }); + + 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__/resolveArtist.test.ts b/lib/research/__tests__/resolveArtist.test.ts new file mode 100644 index 000000000..a08e20daa --- /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 { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; + +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), +})); + +describe("resolveArtist", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns numeric ID directly", async () => { + const result = await resolveArtist("3380"); + + expect(result).toEqual({ id: 3380 }); + expect(fetchChartmetric).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(fetchChartmetric).mockResolvedValue({ + data: { artists: [{ id: 3380, name: "Drake" }] }, + status: 200, + }); + + const result = await resolveArtist("Drake"); + + expect(result).toEqual({ id: 3380 }); + expect(fetchChartmetric).toHaveBeenCalledWith("/search", { + q: "Drake", + type: "artists", + limit: "1", + }); + }); + + it("returns error when no artist found", async () => { + vi.mocked(fetchChartmetric).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(fetchChartmetric).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/__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__/validateArtistRequest.test.ts b/lib/research/__tests__/validateArtistRequest.test.ts new file mode 100644 index 000000000..f4f0661d3 --- /dev/null +++ b/lib/research/__tests__/validateArtistRequest.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateArtistRequest } from "../validateArtistRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("validateArtistRequest", () => { + 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 validateArtistRequest(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 validateArtistRequest(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 validateArtistRequest(new NextRequest("http://x/?artist=Drake")); + + expect(result).toEqual({ accountId: "acc_1", artist: "Drake" }); + }); +}); diff --git a/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts new file mode 100644 index 000000000..767337e81 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts @@ -0,0 +1,111 @@ +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("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", + 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/__tests__/validateGetResearchChartsRequest.test.ts b/lib/research/__tests__/validateGetResearchChartsRequest.test.ts new file mode 100644 index 000000000..7b10ee3a3 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchChartsRequest.test.ts @@ -0,0 +1,114 @@ +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", + }); + }); + + 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/__tests__/validateGetResearchCuratorRequest.test.ts b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts new file mode 100644 index 000000000..cb37e52cf --- /dev/null +++ b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts @@ -0,0 +1,96 @@ +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=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=2"), + ); + expect(result).toBeInstanceOf(NextResponse); + 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 () => { + const result = await validateGetResearchCuratorRequest( + new NextRequest("http://localhost/api/research/curator?platform=spotify"), + ); + expect(result).toBeInstanceOf(NextResponse); + 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=2"), + ); + 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/__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/__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__/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__/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__/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__/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__/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/__tests__/validateGetResearchSearchRequest.test.ts b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts new file mode 100644 index 000000000..db8b8b4cc --- /dev/null +++ b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts @@ -0,0 +1,75 @@ +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, 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", + beta: undefined, + platforms: undefined, + offset: undefined, + }); + }); + + 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" }); + }); + + 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/__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/__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..f51ea81f1 --- /dev/null +++ b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts @@ -0,0 +1,58 @@ +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?id=12345"); + const res = await validateGetResearchTrackRequest(req); + expect(res).toBe(authErr); + }); + + 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("id parameter is required"); + }); + + 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?id=abc"); + const res = await validateGetResearchTrackRequest(req); + 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 the validated request for a numeric id", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track?id=15194376"); + const res = await validateGetResearchTrackRequest(req); + expect(res).toEqual({ accountId: "acc_1", id: "15194376" }); + }); +}); 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/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts new file mode 100644 index 000000000..c8e9208d3 --- /dev/null +++ b/lib/research/getResearchAlbumsHandler.ts @@ -0,0 +1,42 @@ +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 Chartmetric `artist_id`. Thin + * 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`; optional `is_primary`, + * `limit`, `offset` + * @returns JSON album list or error + */ +export async function getResearchAlbumsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchAlbumsRequest(request); + if (validated instanceof NextResponse) return validated; + + const query: Record = { 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); + 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 new file mode 100644 index 000000000..732ddf709 --- /dev/null +++ b/lib/research/getResearchAudienceHandler.ts @@ -0,0 +1,42 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): 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 result = await handleArtistResearch({ + ...validated, + 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); + } catch (error) { + console.error("[ERROR] getResearchAudienceHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts new file mode 100644 index 000000000..fd200a591 --- /dev/null +++ b/lib/research/getResearchCareerHandler.ts @@ -0,0 +1,33 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + path: cmId => `/artist/${cmId}/career`, + }); + + 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/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts new file mode 100644 index 000000000..dcc786f8e --- /dev/null +++ b/lib/research/getResearchChartsHandler.ts @@ -0,0 +1,44 @@ +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 { 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`, `latest`. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchChartsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchChartsRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + accountId: validated.accountId, + path: `/charts/${validated.platform}`, + query: { + country_code: validated.country, + interval: validated.interval, + type: validated.type, + latest: validated.latest, + }, + }); + + 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] getResearchChartsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts new file mode 100644 index 000000000..07860ab77 --- /dev/null +++ b/lib/research/getResearchCitiesHandler.ts @@ -0,0 +1,45 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + path: cmId => `/artist/${cmId}/where-people-listen`, + }); + + 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); + + return successResponse({ cities }); + } catch (error) { + console.error("[ERROR] getResearchCitiesHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts new file mode 100644 index 000000000..a1da8cdb5 --- /dev/null +++ b/lib/research/getResearchCuratorHandler.ts @@ -0,0 +1,38 @@ +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 { validateGetResearchCuratorRequest } from "@/lib/research/validateGetResearchCuratorRequest"; + +/** + * GET /api/research/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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchCuratorHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchCuratorRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + accountId: validated.accountId, + path: `/curator/${validated.platform}/${validated.id}`, + }); + + 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] getResearchCuratorHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts new file mode 100644 index 000000000..3cc507e2d --- /dev/null +++ b/lib/research/getResearchDiscoverHandler.ts @@ -0,0 +1,49 @@ +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 { validateGetResearchDiscoverRequest } from "@/lib/research/validateGetResearchDiscoverRequest"; + +/** + * GET /api/research/discover + * + * 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): 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); + } + + const result = await handleResearch({ + accountId: validated.accountId, + path: "/artist/list/filter", + query, + }); + + 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/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts new file mode 100644 index 000000000..00ce34f60 --- /dev/null +++ b/lib/research/getResearchFestivalsHandler.ts @@ -0,0 +1,32 @@ +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 { validateGetResearchFestivalsRequest } from "@/lib/research/validateGetResearchFestivalsRequest"; + +/** + * GET /api/research/festivals + * + * Returns a list of music festivals. Not artist-scoped — `/festival/list` is a + * global Chartmetric endpoint, so this uses `handleResearch`. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchFestivalsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchFestivalsRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + 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 new file mode 100644 index 000000000..130eb2538 --- /dev/null +++ b/lib/research/getResearchGenresHandler.ts @@ -0,0 +1,31 @@ +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 { validateGetResearchGenresRequest } from "@/lib/research/validateGetResearchGenresRequest"; + +/** + * GET /api/research/genres + * + * 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): Promise { + try { + const validated = await validateGetResearchGenresRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + 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/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts new file mode 100644 index 000000000..103aa4099 --- /dev/null +++ b/lib/research/getResearchInsightsHandler.ts @@ -0,0 +1,34 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + 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 : [] }); + } catch (error) { + console.error("[ERROR] getResearchInsightsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts new file mode 100644 index 000000000..d712d8617 --- /dev/null +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -0,0 +1,41 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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, +): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + 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); + } catch (error) { + console.error("[ERROR] getResearchInstagramPostsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts new file mode 100644 index 000000000..2d6c49178 --- /dev/null +++ b/lib/research/getResearchLookupHandler.ts @@ -0,0 +1,37 @@ +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 { validateGetResearchLookupRequest } from "@/lib/research/validateGetResearchLookupRequest"; + +/** + * GET /api/research/lookup + * + * 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 { + try { + 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); + } +} diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts new file mode 100644 index 000000000..b4351d714 --- /dev/null +++ b/lib/research/getResearchMetricsHandler.ts @@ -0,0 +1,40 @@ +import { type NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateGetResearchMetricsRequest } from "@/lib/research/validateGetResearchMetricsRequest"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateGetResearchMetricsRequest(request); + if (validated instanceof NextResponse) return validated; + + const { source, ...rest } = validated; + const result = await handleArtistResearch({ + ...rest, + 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); + } catch (error) { + console.error("[ERROR] getResearchMetricsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts new file mode 100644 index 000000000..129f6f98b --- /dev/null +++ b/lib/research/getResearchMilestonesHandler.ts @@ -0,0 +1,35 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + path: cmId => `/artist/${cmId}/milestones`, + }); + + 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/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts new file mode 100644 index 000000000..f729bd52e --- /dev/null +++ b/lib/research/getResearchPlaylistHandler.ts @@ -0,0 +1,40 @@ +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 { validateGetResearchPlaylistRequest } from "@/lib/research/validateGetResearchPlaylistRequest"; + +/** + * GET /api/research/playlist + * + * 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 + */ +export async function getResearchPlaylistHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchPlaylistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + accountId: validated.accountId, + path: `/playlist/${validated.platform}/${validated.id}`, + }); + + if ("error" in result) return errorResponse("Playlist 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] getResearchPlaylistHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts new file mode 100644 index 000000000..b2f486fa5 --- /dev/null +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -0,0 +1,63 @@ +import { type NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateGetResearchPlaylistsRequest } from "@/lib/research/validateGetResearchPlaylistsRequest"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +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. + * + * @param request - must include `artist` query param + * @returns JSON playlist placements or error + */ +export async function getResearchPlaylistsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchPlaylistsRequest(request); + if (validated instanceof NextResponse) return validated; + + 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 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 { platform, status, ...rest } = validated; + const result = await handleArtistResearch({ + ...rest, + path: cmId => `/artist/${cmId}/${platform}/${status}/playlists`, + query, + }); + + 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 new file mode 100644 index 000000000..182b1d099 --- /dev/null +++ b/lib/research/getResearchProfileHandler.ts @@ -0,0 +1,38 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + 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); + } catch (error) { + console.error("[ERROR] getResearchProfileHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts new file mode 100644 index 000000000..0537cee9b --- /dev/null +++ b/lib/research/getResearchRadioHandler.ts @@ -0,0 +1,31 @@ +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 { validateGetResearchRadioRequest } from "@/lib/research/validateGetResearchRadioRequest"; + +/** + * GET /api/research/radio + * + * 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): Promise { + try { + const validated = await validateGetResearchRadioRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + 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/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts new file mode 100644 index 000000000..c234ac555 --- /dev/null +++ b/lib/research/getResearchRankHandler.ts @@ -0,0 +1,34 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + 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, + }); + } catch (error) { + console.error("[ERROR] getResearchRankHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts new file mode 100644 index 000000000..ab9ada2bb --- /dev/null +++ b/lib/research/getResearchSearchHandler.ts @@ -0,0 +1,49 @@ +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 { validateGetResearchSearchRequest } from "@/lib/research/validateGetResearchSearchRequest"; + +/** + * 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 { + try { + const validated = await validateGetResearchSearchRequest(request); + if (validated instanceof NextResponse) return validated; + + const query: Record = { + 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, + }); + + if ("error" in result) return errorResponse("Search failed", result.status); + + 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); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts new file mode 100644 index 000000000..b68f75a75 --- /dev/null +++ b/lib/research/getResearchSimilarHandler.ts @@ -0,0 +1,49 @@ +import { type NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateGetResearchSimilarRequest } from "@/lib/research/validateGetResearchSimilarRequest"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; +import { successResponse } from "@/lib/networking/successResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateGetResearchSimilarRequest(request); + if (validated instanceof NextResponse) return validated; + + const { audience, genre, mood, musicality, limit, ...rest } = validated; + const query: Record = { + audience, + genre, + mood, + musicality, + }; + if (limit) query.limit = limit; + + const result = await handleArtistResearch({ + ...rest, + 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, + }); + } catch (error) { + console.error("[ERROR] getResearchSimilarHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts new file mode 100644 index 000000000..c098a0b0b --- /dev/null +++ b/lib/research/getResearchTrackHandler.ts @@ -0,0 +1,39 @@ +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 { validateGetResearchTrackRequest } from "@/lib/research/validateGetResearchTrackRequest"; + +/** + * GET /api/research/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 numeric `id` query param + * @returns JSON track details or error + */ +export async function getResearchTrackHandler(request: NextRequest): Promise { + try { + const validated = await validateGetResearchTrackRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleResearch({ + accountId: validated.accountId, + path: `/track/${validated.id}`, + }); + + 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); + } +} diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts new file mode 100644 index 000000000..1ec1d57aa --- /dev/null +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -0,0 +1,46 @@ +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 { resolveTrack } from "@/lib/research/resolveTrack"; +import { validateGetResearchTrackPlaylistsRequest } from "@/lib/research/validateGetResearchTrackPlaylistsRequest"; + +/** + * 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, filter flags, pagination + * @returns JSON playlist placements for the track or error + */ +export async function getResearchTrackPlaylistsHandler( + request: NextRequest, +): Promise { + 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, validated.accountId); + if (resolved.error) return errorResponse(resolved.error, 404); + trackId = resolved.id; + } + + const result = await handleResearch({ + accountId: validated.accountId, + path: `/track/${trackId}/${validated.platform}/${validated.status}/playlists`, + query: { ...validated.pagination, ...validated.filters }, + }); + + if ("error" in result) return errorResponse(result.error, result.status); + + return successResponse({ + placements: Array.isArray(result.data) ? result.data : [], + }); + } catch (error) { + console.error("[ERROR] getResearchTrackPlaylistsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts new file mode 100644 index 000000000..0e2900f92 --- /dev/null +++ b/lib/research/getResearchTracksHandler.ts @@ -0,0 +1,33 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + path: cmId => `/artist/${cmId}/tracks`, + }); + + 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 new file mode 100644 index 000000000..0b772dae3 --- /dev/null +++ b/lib/research/getResearchUrlsHandler.ts @@ -0,0 +1,39 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + 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 })), + }); + } catch (error) { + console.error("[ERROR] getResearchUrlsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts new file mode 100644 index 000000000..d9550cb50 --- /dev/null +++ b/lib/research/getResearchVenuesHandler.ts @@ -0,0 +1,32 @@ +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 { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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): Promise { + try { + const validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await handleArtistResearch({ + ...validated, + path: cmId => `/artist/${cmId}/venues`, + }); + + 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/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts new file mode 100644 index 000000000..4b0d53eef --- /dev/null +++ b/lib/research/handleArtistResearch.ts @@ -0,0 +1,44 @@ +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +export type HandleArtistResearchParams = { + artist: string; + accountId: string; + path: (cmId: number) => string; + query?: Record; + /** Credits to charge on success. Defaults to 5. */ + credits?: number; +}; + +export type HandleArtistResearchResult = { 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 handleArtistResearch( + params: HandleArtistResearchParams, +): 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 fetchChartmetric(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/handleResearch.ts b/lib/research/handleResearch.ts new file mode 100644 index 000000000..b78e9f22d --- /dev/null +++ b/lib/research/handleResearch.ts @@ -0,0 +1,38 @@ +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +export type HandleResearchParams = { + accountId: string; + path: string; + query?: Record; + /** Credits to charge on success. Defaults to 5. */ + credits?: number; +}; + +export type HandleResearchResult = { 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 handleResearch(params: HandleResearchParams): Promise { + const { accountId, path, query, credits = 5 } = params; + + const result = await fetchChartmetric(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/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..702552b23 --- /dev/null +++ b/lib/research/postResearchEnrichHandler.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { enrichEntity } from "@/lib/parallel/enrichEntity"; +import { validatePostResearchEnrichRequest } from "@/lib/research/validatePostResearchEnrichRequest"; + +/** + * 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 { + try { + const validated = await validatePostResearchEnrichRequest(request); + if (validated instanceof NextResponse) return validated; + + const creditCost = + validated.processor === "ultra" ? 25 : validated.processor === "core" ? 10 : 5; + + const result = await enrichEntity(validated.input, validated.schema, validated.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: validated.accountId, creditsToDeduct: creditCost }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return successResponse({ + output: result.output, + citations: result.citations, + }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : "Enrichment failed", 500); + } +} diff --git a/lib/research/postResearchExtractHandler.ts b/lib/research/postResearchExtractHandler.ts new file mode 100644 index 000000000..cca1842f1 --- /dev/null +++ b/lib/research/postResearchExtractHandler.ts @@ -0,0 +1,40 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { extractUrl } from "@/lib/parallel/extractUrl"; +import { validatePostResearchExtractRequest } from "@/lib/research/validatePostResearchExtractRequest"; + +/** + * 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 { + try { + const validated = await validatePostResearchExtractRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await extractUrl(validated.urls, validated.objective, validated.full_content); + + try { + await deductCredits({ + accountId: validated.accountId, + creditsToDeduct: 5 * validated.urls.length, + }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return successResponse({ + results: result.results, + errors: result.errors.length > 0 ? result.errors : undefined, + }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : "Extract failed", 500); + } +} diff --git a/lib/research/postResearchPeopleHandler.ts b/lib/research/postResearchPeopleHandler.ts new file mode 100644 index 000000000..fd224bdfc --- /dev/null +++ b/lib/research/postResearchPeopleHandler.ts @@ -0,0 +1,35 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { searchPeople } from "@/lib/exa/searchPeople"; +import { validatePostResearchPeopleRequest } from "@/lib/research/validatePostResearchPeopleRequest"; + +/** + * 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 { + try { + const validated = await validatePostResearchPeopleRequest(request); + if (validated instanceof NextResponse) return validated; + + const result = await searchPeople(validated.query, validated.num_results ?? 10); + + try { + await deductCredits({ accountId: validated.accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return successResponse({ results: result.results }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : "People search failed", 500); + } +} diff --git a/lib/research/postResearchWebHandler.ts b/lib/research/postResearchWebHandler.ts new file mode 100644 index 000000000..989234dcc --- /dev/null +++ b/lib/research/postResearchWebHandler.ts @@ -0,0 +1,42 @@ +import { type NextRequest, NextResponse } from "next/server"; +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"; +import { validatePostResearchWebRequest } from "@/lib/research/validatePostResearchWebRequest"; + +/** + * 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 { + try { + const validated = await validatePostResearchWebRequest(request); + if (validated instanceof NextResponse) return validated; + + const searchResponse = await searchPerplexity({ + query: validated.query, + max_results: validated.max_results ?? 10, + max_tokens_per_page: 1024, + ...(validated.country && { country: validated.country }), + }); + + const formatted = formatSearchResultsAsMarkdown(searchResponse); + + try { + await deductCredits({ accountId: validated.accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return successResponse({ + results: searchResponse.results, + formatted, + }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : "Web search failed", 500); + } +} diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts new file mode 100644 index 000000000..17b57a69c --- /dev/null +++ b/lib/research/resolveArtist.ts @@ -0,0 +1,53 @@ +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; + +/** + * 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 fetchChartmetric("/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/resolveTrack.ts b/lib/research/resolveTrack.ts new file mode 100644 index 000000000..de2ff44c5 --- /dev/null +++ b/lib/research/resolveTrack.ts @@ -0,0 +1,80 @@ +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getSearch from "@/lib/spotify/getSearch"; +import { handleResearch } from "@/lib/research/handleResearch"; + +interface GetIdsResponse { + chartmetric_ids?: number[]; +} + +/** + * Resolves a track name (+ optional artist) to a Chartmetric track ID. + * + * 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 | undefined, + accountId: 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 { error: "Failed to authenticate with Spotify" }; + } + + const { data, error } = await getSearch({ + q: searchQuery, + type: "track", + limit: 1, + accessToken: tokenResult.access_token, + }); + + if (error || !data) { + return { error: "Spotify search failed" }; + } + + interface SpotifyTrackItem { + id: string; + name: string; + external_ids?: { isrc?: string }; + } + + const tracks: SpotifyTrackItem[] = data?.tracks?.items ?? []; + if (tracks.length === 0) { + return { error: `No track found matching "${q}"${artist ? ` by ${artist}` : ""}` }; + } + + const spotifyTrack = tracks[0]; + const isrc = spotifyTrack.external_ids?.isrc; + + if (isrc) { + 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) }; + } + } + + const spotifyId = spotifyTrack.id; + 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) }; + } + + return { error: `Could not resolve Chartmetric ID for "${spotifyTrack.name}"` }; +} diff --git a/lib/research/validateArtistRequest.ts b/lib/research/validateArtistRequest.ts new file mode 100644 index 000000000..303a94e46 --- /dev/null +++ b/lib/research/validateArtistRequest.ts @@ -0,0 +1,22 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +/** + * 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 validateArtistRequest( + 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 errorResponse("artist parameter is required", 400); + + return { accountId: authResult.accountId, artist }; +} diff --git a/lib/research/validateGetResearchAlbumsRequest.ts b/lib/research/validateGetResearchAlbumsRequest.ts new file mode 100644 index 000000000..5df0769df --- /dev/null +++ b/lib/research/validateGetResearchAlbumsRequest.ts @@ -0,0 +1,52 @@ +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; + 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` + * (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. + */ +export async function validateGetResearchAlbumsRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + 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); + + 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 }; +} diff --git a/lib/research/validateGetResearchChartsRequest.ts b/lib/research/validateGetResearchChartsRequest.ts new file mode 100644 index 000000000..8c777193d --- /dev/null +++ b/lib/research/validateGetResearchChartsRequest.ts @@ -0,0 +1,61 @@ +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; + 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"). `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. + */ +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); + + 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, + type, + latest, + }; +} diff --git a/lib/research/validateGetResearchCuratorRequest.ts b/lib/research/validateGetResearchCuratorRequest.ts new file mode 100644 index 000000000..08386ae84 --- /dev/null +++ b/lib/research/validateGetResearchCuratorRequest.ts @@ -0,0 +1,42 @@ +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: Platform; + id: string; +}; + +/** + * Validates `GET /api/research/curator` — auth + required `platform` (enum) + * and `id` (numeric Chartmetric curator ID). + * + * @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) 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: platform as Platform, id }; +} diff --git a/lib/research/validateGetResearchDiscoverRequest.ts b/lib/research/validateGetResearchDiscoverRequest.ts new file mode 100644 index 000000000..af0cfb364 --- /dev/null +++ b/lib/research/validateGetResearchDiscoverRequest.ts @@ -0,0 +1,54 @@ +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({ + 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; + +export type ValidatedGetResearchDiscoverRequest = DiscoverQuery & { accountId: string }; + +/** + * Validates `GET /api/research/discover` — auth + filter query params. + * + * @param request - The incoming HTTP request. + */ +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", + "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 { accountId: authResult.accountId, ...result.data }; +} 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/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/validateGetResearchMetricsRequest.ts b/lib/research/validateGetResearchMetricsRequest.ts new file mode 100644 index 000000000..a85207767 --- /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 validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + return { ...validated, source }; +} diff --git a/lib/research/validateGetResearchPlaylistRequest.ts b/lib/research/validateGetResearchPlaylistRequest.ts new file mode 100644 index 000000000..7a673640b --- /dev/null +++ b/lib/research/validateGetResearchPlaylistRequest.ts @@ -0,0 +1,39 @@ +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` (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. + */ +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/validateGetResearchPlaylistsRequest.ts b/lib/research/validateGetResearchPlaylistsRequest.ts new file mode 100644 index 000000000..b8963f402 --- /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 validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + return { ...validated, platform, status }; +} 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 }; +} diff --git a/lib/research/validateGetResearchSearchRequest.ts b/lib/research/validateGetResearchSearchRequest.ts new file mode 100644 index 000000000..dfd75bf5a --- /dev/null +++ b/lib/research/validateGetResearchSearchRequest.ts @@ -0,0 +1,43 @@ +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; + 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"). 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. + */ +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", + beta: searchParams.get("beta") ?? undefined, + platforms: searchParams.get("platforms") ?? undefined, + offset: searchParams.get("offset") ?? undefined, + }; +} diff --git a/lib/research/validateGetResearchSimilarRequest.ts b/lib/research/validateGetResearchSimilarRequest.ts new file mode 100644 index 000000000..5a6fbbbe7 --- /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 validated = await validateArtistRequest(request); + if (validated instanceof NextResponse) return validated; + + return { ...validated, ...axes, limit: searchParams.get("limit") ?? undefined }; +} 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..d31c14e8e --- /dev/null +++ b/lib/research/validateGetResearchTrackRequest.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 ValidatedGetResearchTrackRequest = { + accountId: string; + id: string; +}; + +/** + * 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. + */ +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 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); + + return { accountId: authResult.accountId, id }; +} 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 }; +}