Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/api/admins/artists/pro/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
80 changes: 80 additions & 0 deletions lib/artists/__tests__/getArtistsProHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
51 changes: 51 additions & 0 deletions lib/artists/__tests__/validateGetArtistsProRequest.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions lib/artists/getArtistsProHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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() },
);
}
}
30 changes: 30 additions & 0 deletions lib/artists/getProArtists.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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))),
);
}
18 changes: 18 additions & 0 deletions lib/artists/validateGetArtistsProRequest.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | Record<string, never>> {
const auth = await validateAdminAuth(request);
if (auth instanceof NextResponse) {
return auth;
}
return {};
}
12 changes: 12 additions & 0 deletions lib/enterprise/consts.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set([
"recoupable.com",
"rostrum.com",
"spaceheatermusic.io",
"fatbeats.com",
"cantorarecords.net",
"rostrumrecords.com",
]);
18 changes: 18 additions & 0 deletions lib/enterprise/getEnterpriseAccountIds.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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));
}
21 changes: 21 additions & 0 deletions lib/supabase/account_artist_ids/selectAccountArtistIds.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [];
}
21 changes: 11 additions & 10 deletions lib/supabase/account_emails/selectAccountEmails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `%@<domain>%` — matches the legacy behavior that also hits
* `user+tag@<domain>.anything`).
*/
export default async function selectAccountEmails({
emails,
accountIds,
domain,
}: {
emails?: string[];
accountIds?: string | string[];
domain?: string;
}): Promise<Tables<"account_emails">[]> {
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);
}
Expand All @@ -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) {
Expand Down
35 changes: 35 additions & 0 deletions lib/supabase/subscriptions/selectSubscriptions.ts
Original file line number Diff line number Diff line change
@@ -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<Tables<"subscriptions">[]> {
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 || [];
}
Loading