From 73fa2ad82a1753c205d9b70973e382c59c9c7393 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 25 Apr 2026 00:51:58 +0530 Subject: [PATCH] feat(api): migrate GET /api/artists/pro (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): migrate GET /api/artists/pro Admin-scoped flat route returning the deduped list of artist IDs owned by "pro" accounts (enterprise email domain or active Stripe subscription). Ports Stripe client + getActiveSubscriptions inline until the subscription PR lands; reuses validateAdminAuth for scope. * fix(stripe): defer client init to first call Next build on Vercel evaluates lib/stripe/client.ts while collecting page data; throwing at module-load when STRIPE_SECRET_KEY is absent from the preview env killed the build. Move the guard into a lazy factory so build-time import is always safe — the first request still surfaces a 500 via the handler's catch when the secret is genuinely missing. Co-Authored-By: Claude Opus 4.7 * refactor: fold account_emails helpers into generic selectAccountEmails Add optional domain filter (ilike %@%) to the existing selectAccountEmails helper and drop the per-filter wrappers. One helper, three optional filters (emails, accountIds, domain). * refactor(stripe): paginate getActiveSubscriptions, drop unused accountId Use Stripe SDK's async iterator so we're not capped at 100 rows — the cursor paginates the full active set. The accountId filter param was dead (no caller passed it); drop rather than carry; reintroduce via Stripe Search API if a single-account lookup is ever needed. * refactor(stripe): use autoPagingToArray in getActiveSubscriptions * refactor(stripe): stream active subscriptions, use native status filter Swap getActiveSubscriptions for an iterateActiveSubscriptions async generator. Two wins: 1. status: 'active' is Stripe's native filter — no current_period_end vs server-clock comparison (timezone / skew hazards). 2. Yielded incrementally so callers fold results into a Set as they arrive; no hard in-memory cap on the active subscription count. Caller now holds only the deduped accountId strings, not full Subscription objects. * refactor: separate stripe from enterprise; rename merge to getProArtists - lib/stripe/ now owns getActiveSubscriptions + getSubscriberAccountEmails (they query Stripe, not enterprise domains). - lib/enterprise/ keeps only ENTERPRISE_DOMAINS and the domain email fan-out. - The merge orchestrator moves to lib/artists/getProArtists.ts (enterprise ∪ subscribers); renamed from getEnterpriseArtists since 'enterprise artists' understates what it returns. - getActiveSubscriptions uses a manual starting_after cursor loop so there is no iterator and no hard row cap. * refactor: project pro sources to account_ids, skip subscriber email round-trip The subscriber path fetched Stripe subs, extracted metadata.accountId, round-tripped Supabase to get account_emails rows, then unpacked account_id again — the email lookup was inert. - lib/stripe/getSubscriberAccountIds reads metadata.accountId from Stripe directly; no Supabase call. - lib/enterprise/getEnterpriseAccountIds projects the ilike result to account_ids at the source. - getProArtists now merges two string arrays, dedupes once, then hits account_artist_ids. * refactor: inline subscriber accountId extraction into getProArtists getSubscriberAccountIds was a two-line .map/.filter wrapper. Drop it and inline against the Stripe result — no useful indirection. * refactor(stripe): return account_ids directly, skip Subscription hop getActiveSubscriptions returned full Subscription objects only to have every caller project down to metadata.accountId. Collapse that into getActiveSubscriptionAccountIds — strings in, strings out. * refactor: query local subscriptions mirror instead of Stripe Supabase has a subscriptions table mirroring billing-provider state (stripe/lemon-squeezy/paddle) with account_id + active flag. Read from that and skip Stripe entirely — no pagination, no secret-key env var, no provider round-trip, and cross-provider correct. - lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts - lib/stripe/ removed - stripe package uninstalled getProArtists now merges two Supabase queries. * refactor: move route to /api/admins/artists/pro * refactor: generalize to selectSubscriptions helper Per review — replace selectActiveSubscriptionAccountIds with a generic selectSubscriptions({ accountIds?, active? }) helper. Caller projects to account_ids. --------- Co-authored-by: Claude Opus 4.7 --- app/api/admins/artists/pro/route.ts | 29 +++++++ .../__tests__/getArtistsProHandler.test.ts | 80 +++++++++++++++++++ .../validateGetArtistsProRequest.test.ts | 51 ++++++++++++ lib/artists/getArtistsProHandler.ts | 27 +++++++ lib/artists/getProArtists.ts | 30 +++++++ lib/artists/validateGetArtistsProRequest.ts | 18 +++++ lib/enterprise/consts.ts | 12 +++ lib/enterprise/getEnterpriseAccountIds.ts | 18 +++++ .../selectAccountArtistIds.ts | 21 +++++ .../account_emails/selectAccountEmails.ts | 21 ++--- .../subscriptions/selectSubscriptions.ts | 35 ++++++++ 11 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 app/api/admins/artists/pro/route.ts create mode 100644 lib/artists/__tests__/getArtistsProHandler.test.ts create mode 100644 lib/artists/__tests__/validateGetArtistsProRequest.test.ts create mode 100644 lib/artists/getArtistsProHandler.ts create mode 100644 lib/artists/getProArtists.ts create mode 100644 lib/artists/validateGetArtistsProRequest.ts create mode 100644 lib/enterprise/consts.ts create mode 100644 lib/enterprise/getEnterpriseAccountIds.ts create mode 100644 lib/supabase/account_artist_ids/selectAccountArtistIds.ts create mode 100644 lib/supabase/subscriptions/selectSubscriptions.ts diff --git a/app/api/admins/artists/pro/route.ts b/app/api/admins/artists/pro/route.ts new file mode 100644 index 000000000..3375ff910 --- /dev/null +++ b/app/api/admins/artists/pro/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getArtistsProHandler } from "@/lib/artists/getArtistsProHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/admins/artists/pro + * + * Returns a deduplicated list of artist IDs owned by "pro" accounts + * (enterprise-domain emails or active Stripe subscriptions). Admin-scoped — + * the response is the paying-customer list. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ status, artists, [error] }`. + */ +export async function GET(request: NextRequest) { + return getArtistsProHandler(request); +} diff --git a/lib/artists/__tests__/getArtistsProHandler.test.ts b/lib/artists/__tests__/getArtistsProHandler.test.ts new file mode 100644 index 000000000..638f5be41 --- /dev/null +++ b/lib/artists/__tests__/getArtistsProHandler.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getArtistsProHandler } from "../getArtistsProHandler"; +import { validateGetArtistsProRequest } from "../validateGetArtistsProRequest"; +import { getProArtists } from "@/lib/artists/getProArtists"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetArtistsProRequest", () => ({ + validateGetArtistsProRequest: vi.fn(), +})); + +vi.mock("@/lib/artists/getProArtists", () => ({ + getProArtists: vi.fn(), +})); + +describe("getArtistsProHandler", () => { + const request = new NextRequest("http://localhost/api/admins/artists/pro"); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateGetArtistsProRequest).mockResolvedValue({}); + }); + + it.each([ + ["enterprise-only", ["a1", "a2"]], + ["subscription-only", ["b1"]], + ["both enterprise and subscription, deduped", ["a1", "b1", "c1"]], + ["neither — empty", []], + ])("returns %s artists on 200", async (_label, artists) => { + vi.mocked(getProArtists).mockResolvedValue(artists); + + const response = await getArtistsProHandler(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ status: "success", artists }); + }); + + it("propagates the 401 response when validator fails auth", async () => { + const err = NextResponse.json({ status: "error", error: "no auth" }, { status: 401 }); + vi.mocked(validateGetArtistsProRequest).mockResolvedValue(err); + + const response = await getArtistsProHandler(request); + + expect(response).toBe(err); + expect(getProArtists).not.toHaveBeenCalled(); + }); + + it("propagates the 403 response for non-admin callers", async () => { + const err = NextResponse.json({ status: "error", message: "Forbidden" }, { status: 403 }); + vi.mocked(validateGetArtistsProRequest).mockResolvedValue(err); + + const response = await getArtistsProHandler(request); + + expect(response).toBe(err); + expect(getProArtists).not.toHaveBeenCalled(); + }); + + it("returns a generic 500 without leaking error details", async () => { + vi.mocked(getProArtists).mockRejectedValue( + new Error("db.internal.host=10.0.0.5 connection refused"), + ); + + const response = await getArtistsProHandler(request); + const bodyText = JSON.stringify(await response.json()); + + expect(response.status).toBe(500); + expect(bodyText).not.toContain("db.internal"); + expect(bodyText).not.toContain("10.0.0.5"); + expect(bodyText).not.toContain("connection refused"); + expect(JSON.parse(bodyText)).toEqual({ + status: "error", + artists: [], + error: "Internal server error", + }); + }); +}); diff --git a/lib/artists/__tests__/validateGetArtistsProRequest.test.ts b/lib/artists/__tests__/validateGetArtistsProRequest.test.ts new file mode 100644 index 000000000..ecad010ed --- /dev/null +++ b/lib/artists/__tests__/validateGetArtistsProRequest.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetArtistsProRequest } from "../validateGetArtistsProRequest"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/admins/validateAdminAuth", () => ({ + validateAdminAuth: vi.fn(), +})); + +describe("validateGetArtistsProRequest", () => { + const request = new NextRequest("http://localhost/api/admins/artists/pro"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns an empty payload when admin auth passes", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "t", + }); + + const result = await validateGetArtistsProRequest(request); + + expect(result).toEqual({}); + expect(validateAdminAuth).toHaveBeenCalledWith(request); + }); + + it("propagates the 401 NextResponse when auth is missing", async () => { + const err = NextResponse.json({ status: "error", error: "no auth" }, { status: 401 }); + vi.mocked(validateAdminAuth).mockResolvedValue(err); + + const result = await validateGetArtistsProRequest(request); + + expect(result).toBe(err); + }); + + it("propagates the 403 NextResponse when caller is not admin", async () => { + const err = NextResponse.json({ status: "error", message: "Forbidden" }, { status: 403 }); + vi.mocked(validateAdminAuth).mockResolvedValue(err); + + const result = await validateGetArtistsProRequest(request); + + expect(result).toBe(err); + }); +}); diff --git a/lib/artists/getArtistsProHandler.ts b/lib/artists/getArtistsProHandler.ts new file mode 100644 index 000000000..9036cb250 --- /dev/null +++ b/lib/artists/getArtistsProHandler.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getProArtists } from "@/lib/artists/getProArtists"; +import { validateGetArtistsProRequest } from "@/lib/artists/validateGetArtistsProRequest"; + +export async function getArtistsProHandler(request: NextRequest): Promise { + try { + const validated = await validateGetArtistsProRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const artists = await getProArtists(); + + return NextResponse.json( + { status: "success", artists }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + // Never leak error.message — it can surface DB hosts / stack hints. + console.error("[ERROR] getArtistsProHandler:", error); + return NextResponse.json( + { status: "error", artists: [], error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/artists/getProArtists.ts b/lib/artists/getProArtists.ts new file mode 100644 index 000000000..35cdf3894 --- /dev/null +++ b/lib/artists/getProArtists.ts @@ -0,0 +1,30 @@ +import { getEnterpriseAccountIds } from "@/lib/enterprise/getEnterpriseAccountIds"; +import selectSubscriptions from "@/lib/supabase/subscriptions/selectSubscriptions"; +import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds"; + +/** + * Deduped artist_ids owned by "pro" accounts — enterprise email domain OR + * active Stripe subscription. Dedupes at both layers (account_id then + * artist_id) so accounts present in both sources and artists shared across + * accounts do not produce cartesian-product rows. Short-circuits before the + * account_artist_ids query when no pro accounts are found. + */ +export async function getProArtists(): Promise { + const [enterpriseIds, subscriptionRows] = await Promise.all([ + getEnterpriseAccountIds(), + selectSubscriptions({ active: true }), + ]); + + const subscriberIds = subscriptionRows + .map(row => row.account_id) + .filter((id): id is string => Boolean(id)); + + const accountIds = Array.from(new Set([...enterpriseIds, ...subscriberIds])); + if (accountIds.length === 0) return []; + + const artistIdRows = await selectAccountArtistIds(accountIds); + + return Array.from( + new Set(artistIdRows.map(row => row.artist_id).filter((id): id is string => Boolean(id))), + ); +} diff --git a/lib/artists/validateGetArtistsProRequest.ts b/lib/artists/validateGetArtistsProRequest.ts new file mode 100644 index 000000000..25b4ffe28 --- /dev/null +++ b/lib/artists/validateGetArtistsProRequest.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; + +/** + * Scope, not access: the response is the list of all pro-tier artists — the + * paying-customer set. There is no per-row id to bind an access check to, so + * admin-scope is enforced at the request boundary. Reuses `validateAdminAuth` + * (validateAuthContext + Recoup-org membership check) rather than forking. + */ +export async function validateGetArtistsProRequest( + request: NextRequest, +): Promise> { + const auth = await validateAdminAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + return {}; +} diff --git a/lib/enterprise/consts.ts b/lib/enterprise/consts.ts new file mode 100644 index 000000000..42309cffd --- /dev/null +++ b/lib/enterprise/consts.ts @@ -0,0 +1,12 @@ +/** + * Email domains that unconditionally mark an account as "pro" regardless of + * Stripe subscription state. Ported from Recoup-Agent-APIs/lib/consts.ts. + */ +export const ENTERPRISE_DOMAINS: ReadonlySet = new Set([ + "recoupable.com", + "rostrum.com", + "spaceheatermusic.io", + "fatbeats.com", + "cantorarecords.net", + "rostrumrecords.com", +]); diff --git a/lib/enterprise/getEnterpriseAccountIds.ts b/lib/enterprise/getEnterpriseAccountIds.ts new file mode 100644 index 000000000..fb2f9a74b --- /dev/null +++ b/lib/enterprise/getEnterpriseAccountIds.ts @@ -0,0 +1,18 @@ +import { ENTERPRISE_DOMAINS } from "@/lib/enterprise/consts"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Resolve account_ids whose email belongs to an enterprise domain. Fans out + * one ilike query per domain in parallel and projects down to account_ids — + * the email rows themselves aren't consumed downstream. + */ +export async function getEnterpriseAccountIds(): Promise { + const rowsPerDomain = await Promise.all( + Array.from(ENTERPRISE_DOMAINS).map(domain => selectAccountEmails({ domain })), + ); + + return rowsPerDomain + .flat() + .map(row => row.account_id) + .filter((id): id is string => Boolean(id)); +} diff --git a/lib/supabase/account_artist_ids/selectAccountArtistIds.ts b/lib/supabase/account_artist_ids/selectAccountArtistIds.ts new file mode 100644 index 000000000..bb1d97f11 --- /dev/null +++ b/lib/supabase/account_artist_ids/selectAccountArtistIds.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Project `artist_id` values for the given account_ids. Returns `[]` when + * `account_ids` is empty to avoid the undefined `.in(..., [])` behavior. + */ +export async function selectAccountArtistIds(accountIds: string[]) { + if (accountIds.length === 0) return []; + + const { data, error } = await supabase + .from("account_artist_ids") + .select("artist_id") + .in("account_id", accountIds); + + if (error) { + console.error("Error fetching account_artist_ids:", error); + return []; + } + + return data ?? []; +} diff --git a/lib/supabase/account_emails/selectAccountEmails.ts b/lib/supabase/account_emails/selectAccountEmails.ts index 81f055302..832c800ac 100644 --- a/lib/supabase/account_emails/selectAccountEmails.ts +++ b/lib/supabase/account_emails/selectAccountEmails.ts @@ -2,33 +2,30 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; /** - * Select account_emails by email addresses and/or account IDs - * - * @param params - The parameters for the query - * @param params.emails - Optional array of email addresses to query - * @param params.accountIds - Optional array of account IDs to query - * @returns Array of account_emails rows + * Select account_emails by email addresses, account IDs, and/or a domain + * substring (ilike `%@%` — matches the legacy behavior that also hits + * `user+tag@.anything`). */ export default async function selectAccountEmails({ emails, accountIds, + domain, }: { emails?: string[]; accountIds?: string | string[]; + domain?: string; }): Promise[]> { let query = supabase.from("account_emails").select("*"); - // Build query based on provided parameters const ids = accountIds ? (Array.isArray(accountIds) ? accountIds : [accountIds]) : []; const hasEmails = Array.isArray(emails) && emails.length > 0; const hasAccountIds = ids.length > 0; + const hasDomain = typeof domain === "string" && domain.length > 0; - // If neither parameter is provided, return empty array - if (!hasEmails && !hasAccountIds) { + if (!hasEmails && !hasAccountIds && !hasDomain) { return []; } - // Apply filters if (hasEmails) { query = query.in("email", emails); } @@ -37,6 +34,10 @@ export default async function selectAccountEmails({ query = query.in("account_id", ids); } + if (hasDomain) { + query = query.ilike("email", `%@${domain}%`); + } + const { data, error } = await query; if (error) { diff --git a/lib/supabase/subscriptions/selectSubscriptions.ts b/lib/supabase/subscriptions/selectSubscriptions.ts new file mode 100644 index 000000000..9acdf289e --- /dev/null +++ b/lib/supabase/subscriptions/selectSubscriptions.ts @@ -0,0 +1,35 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select subscription rows, optionally filtered by account IDs and/or active + * state. The `subscriptions` table is the local mirror of cross-provider + * billing state (stripe, lemon-squeezy, paddle). + */ +export default async function selectSubscriptions({ + accountIds, + active, +}: { + accountIds?: string | string[]; + active?: boolean; +} = {}): Promise[]> { + let query = supabase.from("subscriptions").select("*"); + + if (accountIds !== undefined) { + const ids = Array.isArray(accountIds) ? accountIds : [accountIds]; + query = query.in("account_id", ids); + } + + if (active !== undefined) { + query = query.eq("active", active); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching subscriptions:", error); + return []; + } + + return data || []; +}