From 63b1a65c1bc680ea1fbd92162d84834fa58989b7 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Fri, 20 Mar 2026 00:06:30 +0000 Subject: [PATCH 1/3] feat: add Chartmetric proxy endpoint (Option A for credits) Implements POST/GET /api/chartmetric/[...path] that authenticates via validateAuthContext, deducts 1 credit per call, exchanges the server-side CHARTMETRIC_REFRESH_TOKEN for an access token, and forwards the request to the Chartmetric API. Includes full vitest test coverage (5 tests). Co-Authored-By: Claude Sonnet 4.6 --- app/api/chartmetric/[...path]/route.ts | 38 ++++ .../__tests__/proxyChartmetricRequest.test.ts | 171 ++++++++++++++++++ lib/chartmetric/getChartmetricToken.ts | 33 ++++ lib/chartmetric/proxyChartmetricRequest.ts | 101 +++++++++++ 4 files changed, 343 insertions(+) create mode 100644 app/api/chartmetric/[...path]/route.ts create mode 100644 lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts create mode 100644 lib/chartmetric/getChartmetricToken.ts create mode 100644 lib/chartmetric/proxyChartmetricRequest.ts diff --git a/app/api/chartmetric/[...path]/route.ts b/app/api/chartmetric/[...path]/route.ts new file mode 100644 index 00000000..c9163b94 --- /dev/null +++ b/app/api/chartmetric/[...path]/route.ts @@ -0,0 +1,38 @@ +import { type NextRequest } from "next/server"; +import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest"; + +/** + * GET /api/chartmetric/[...path] + * + * Proxies GET requests to the Chartmetric API on behalf of an authenticated account. + * Deducts 1 credit per call. + * + * @param request - Incoming API request. + * @param context - Route context containing the Chartmetric path segments. + * @param context.params - Route params with Chartmetric path segments. + * @param context.params.path - Array of path segments to forward to Chartmetric. + * @returns The Chartmetric API response. + */ +export async function GET(request: NextRequest, context: { params: { path: string[] } }) { + return proxyChartmetricRequest(request, context.params); +} + +/** + * POST /api/chartmetric/[...path] + * + * Proxies POST requests to the Chartmetric API on behalf of an authenticated account. + * Deducts 1 credit per call. + * + * @param request - Incoming API request. + * @param context - Route context containing the Chartmetric path segments. + * @param context.params - Route params with Chartmetric path segments. + * @param context.params.path - Array of path segments to forward to Chartmetric. + * @returns The Chartmetric API response. + */ +export async function POST(request: NextRequest, context: { params: { path: string[] } }) { + return proxyChartmetricRequest(request, context.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts b/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts new file mode 100644 index 00000000..ef715cef --- /dev/null +++ b/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ + getChartmetricToken: vi.fn(), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("proxyChartmetricRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("error cases", () => { + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + + expect(result.status).toBe(401); + }); + + it("returns 402 when credits are insufficient", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token_abc", + }); + vi.mocked(deductCredits).mockRejectedValue( + new Error("Insufficient credits. Required: 1, Available: 0"), + ); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + const body = await result.json(); + + expect(result.status).toBe(402); + expect(body.status).toBe("error"); + expect(body.error).toBe("Insufficient credits for Chartmetric API call"); + }); + + it("returns 500 when Chartmetric API call fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token_abc", + }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 9 }); + vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token"); + mockFetch.mockRejectedValue(new Error("Network error")); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); + + describe("successful cases", () => { + it("successfully proxies a GET request and returns Chartmetric response", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token_abc", + }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 9 }); + vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token"); + + const chartmetricData = { id: 123, name: "Test Artist" }; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => chartmetricData, + }); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body).toEqual(chartmetricData); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.chartmetric.com/api/artist/123", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer cm_access_token", + }), + }), + ); + expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_123", creditsToDeduct: 1 }); + }); + + it("successfully proxies a POST request with body", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_456", + orgId: null, + authToken: "token_xyz", + }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 8 }); + vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token_2"); + + const chartmetricData = { success: true }; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => chartmetricData, + }); + + const requestBody = { filter: "test" }; + const request = new NextRequest("http://localhost/api/chartmetric/search", { + method: "POST", + body: JSON.stringify(requestBody), + headers: { "Content-Type": "application/json" }, + }); + + const result = await proxyChartmetricRequest(request, { path: ["search"] }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body).toEqual(chartmetricData); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.chartmetric.com/api/search", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer cm_access_token_2", + }), + }), + ); + }); + }); +}); diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts new file mode 100644 index 00000000..c2fc3615 --- /dev/null +++ b/lib/chartmetric/getChartmetricToken.ts @@ -0,0 +1,33 @@ +/** + * Exchanges the Chartmetric refresh token for a short-lived access token. + * + * @returns The Chartmetric access token string. + * @throws Error if the token exchange fails or the env variable is missing. + */ +export async function getChartmetricToken(): Promise { + const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; + + if (!refreshToken) { + throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); + } + + const response = await fetch("https://api.chartmetric.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshtoken: refreshToken }), + }); + + if (!response.ok) { + throw new Error(`Chartmetric token exchange failed with status ${response.status}`); + } + + const data = (await response.json()) as { access_token: string; expires_in: number }; + + if (!data.access_token) { + throw new Error("Chartmetric token response did not include an access_token"); + } + + return data.access_token; +} diff --git a/lib/chartmetric/proxyChartmetricRequest.ts b/lib/chartmetric/proxyChartmetricRequest.ts new file mode 100644 index 00000000..449d3c08 --- /dev/null +++ b/lib/chartmetric/proxyChartmetricRequest.ts @@ -0,0 +1,101 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; + +/** + * Proxy handler for Chartmetric API requests. + * + * Authenticates the caller, deducts 1 credit, then forwards the request + * to the Chartmetric API using a server-side access token. + * + * @param request - The incoming NextRequest. + * @param params - Route params containing the Chartmetric path segments. + * @param params.path - Array of path segments to forward to the Chartmetric API. + * @returns The Chartmetric API response forwarded as JSON. + */ +export async function proxyChartmetricRequest( + request: NextRequest, + params: { path: string[] }, +): Promise { + // 1. Authenticate + const authResult = await validateAuthContext(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + // 2. Deduct 1 credit + try { + await deductCredits({ accountId, creditsToDeduct: 1 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + if (message.toLowerCase().includes("insufficient credits")) { + return NextResponse.json( + { status: "error", error: "Insufficient credits for Chartmetric API call" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { status: "error", error: "Failed to deduct credits" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + // 3. Get Chartmetric access token + let accessToken: string; + try { + accessToken = await getChartmetricToken(); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { status: "error", error: `Failed to obtain Chartmetric token: ${message}` }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + // 4. Build the Chartmetric URL, preserving query params + const pathSegments = params.path.join("/"); + const originalUrl = new URL(request.url); + const chartmetricUrl = `https://api.chartmetric.com/api/${pathSegments}${originalUrl.search}`; + + // 5. Read the request body for non-GET methods + let body: string | undefined; + if (request.method !== "GET" && request.method !== "HEAD") { + try { + body = await request.text(); + } catch { + // If body reading fails, proceed without a body + } + } + + // 6. Forward the request to Chartmetric + try { + const chartmetricResponse = await fetch(chartmetricUrl, { + method: request.method, + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + ...(body ? { body } : {}), + }); + + const data = await chartmetricResponse.json(); + + return NextResponse.json(data, { + status: chartmetricResponse.status, + headers: getCorsHeaders(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { status: "error", error: `Chartmetric API request failed: ${message}` }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} From 21ba61f8b7b099be08d5b2571e5f984743fd4cdb Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Fri, 20 Mar 2026 00:38:33 +0000 Subject: [PATCH 2/3] fix: increase Chartmetric proxy credit cost from 1 to 5 credits per call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chartmetric costs $350/month flat. At 5 credits ($0.05/call), we break even at ~7,000 calls/month (~233/day). A typical research task (6-7 API calls) costs 30-35 credits ($0.30-0.35), which is fair pricing for the data value delivered. At 1 credit/call we would need 35,000 calls/ month to break even — unrealistic at current scale. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/proxyChartmetricRequest.test.ts | 10 +++++----- lib/chartmetric/proxyChartmetricRequest.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts b/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts index ef715cef..0c0459a1 100644 --- a/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts +++ b/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts @@ -54,7 +54,7 @@ describe("proxyChartmetricRequest", () => { authToken: "token_abc", }); vi.mocked(deductCredits).mockRejectedValue( - new Error("Insufficient credits. Required: 1, Available: 0"), + new Error("Insufficient credits. Required: 5, Available: 0"), ); const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { @@ -75,7 +75,7 @@ describe("proxyChartmetricRequest", () => { orgId: null, authToken: "token_abc", }); - vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 9 }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 }); vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token"); mockFetch.mockRejectedValue(new Error("Network error")); @@ -98,7 +98,7 @@ describe("proxyChartmetricRequest", () => { orgId: null, authToken: "token_abc", }); - vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 9 }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 }); vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token"); const chartmetricData = { id: 123, name: "Test Artist" }; @@ -126,7 +126,7 @@ describe("proxyChartmetricRequest", () => { }), }), ); - expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_123", creditsToDeduct: 1 }); + expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_123", creditsToDeduct: 5 }); }); it("successfully proxies a POST request with body", async () => { @@ -135,7 +135,7 @@ describe("proxyChartmetricRequest", () => { orgId: null, authToken: "token_xyz", }); - vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 8 }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 }); vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token_2"); const chartmetricData = { success: true }; diff --git a/lib/chartmetric/proxyChartmetricRequest.ts b/lib/chartmetric/proxyChartmetricRequest.ts index 449d3c08..fe2e20bd 100644 --- a/lib/chartmetric/proxyChartmetricRequest.ts +++ b/lib/chartmetric/proxyChartmetricRequest.ts @@ -7,9 +7,13 @@ import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; /** * Proxy handler for Chartmetric API requests. * - * Authenticates the caller, deducts 1 credit, then forwards the request + * Authenticates the caller, deducts 5 credits, then forwards the request * to the Chartmetric API using a server-side access token. * + * Credit cost rationale: Chartmetric costs $350/month for API access. At 5 credits + * ($0.05) per call, we break even at ~7,000 calls/month (~233/day). A typical + * research task (6–7 API calls) costs 30–35 credits ($0.30–$0.35). + * * @param request - The incoming NextRequest. * @param params - Route params containing the Chartmetric path segments. * @param params.path - Array of path segments to forward to the Chartmetric API. @@ -28,9 +32,9 @@ export async function proxyChartmetricRequest( const { accountId } = authResult; - // 2. Deduct 1 credit + // 2. Deduct 5 credits per Chartmetric API call try { - await deductCredits({ accountId, creditsToDeduct: 1 }); + await deductCredits({ accountId, creditsToDeduct: 5 }); } catch (err) { const message = err instanceof Error ? err.message : String(err); From 0df36b580455dc89dfe2a40d3994ef1627d5751e Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Fri, 20 Mar 2026 13:31:13 +0000 Subject: [PATCH 3/3] fix: await async params in Chartmetric proxy route for Next.js 15 compatibility Co-Authored-By: Claude Sonnet 4.6 --- app/api/chartmetric/[...path]/route.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/api/chartmetric/[...path]/route.ts b/app/api/chartmetric/[...path]/route.ts index c9163b94..8e98b6ee 100644 --- a/app/api/chartmetric/[...path]/route.ts +++ b/app/api/chartmetric/[...path]/route.ts @@ -13,8 +13,9 @@ import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricReque * @param context.params.path - Array of path segments to forward to Chartmetric. * @returns The Chartmetric API response. */ -export async function GET(request: NextRequest, context: { params: { path: string[] } }) { - return proxyChartmetricRequest(request, context.params); +export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const params = await context.params; + return proxyChartmetricRequest(request, params); } /** @@ -29,8 +30,9 @@ export async function GET(request: NextRequest, context: { params: { path: strin * @param context.params.path - Array of path segments to forward to Chartmetric. * @returns The Chartmetric API response. */ -export async function POST(request: NextRequest, context: { params: { path: string[] } }) { - return proxyChartmetricRequest(request, context.params); +export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const params = await context.params; + return proxyChartmetricRequest(request, params); } export const dynamic = "force-dynamic";