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";

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Module should export a single primary function whose name matches the filename

Module exports multiple top-level functions instead of a single primary export, violating the filename-to-export convention.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/artists/pro/route.ts, line 27:

<comment>Module exports multiple top-level functions instead of a single primary export, violating the filename-to-export convention.</comment>

<file context>
@@ -0,0 +1,29 @@
+ * @param request - The incoming request.
+ * @returns A NextResponse with `{ status, artists, [error] }`.
+ */
+export async function GET(request: NextRequest) {
+  return getArtistsProHandler(request);
+}
</file context>
Fix with Cubic

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] }`.
*/
Comment on lines +17 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stale JSDoc: "active Stripe subscriptions" no longer reflects the implementation.

Per the PR's commit history, later commits replaced the Stripe API calls with reads from a local Supabase subscriptions mirror. Keeping the Stripe reference in this doc comment will mislead future readers hunting down the source of truth for "active subscription" semantics — a small clean-code nit, but docs that drift from reality erode trust faster than bad code does.

📝 Suggested wording tweak
 /**
  * GET /api/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.
+ * (enterprise-domain emails or accounts with an active subscription in the
+ * local subscriptions mirror). Admin-scoped — the response is the
+ * paying-customer list.
  *
  * `@param` request - The incoming request.
  * `@returns` A NextResponse with `{ status, artists, [error] }`.
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* GET /api/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] }`.
*/
/**
* GET /api/artists/pro
*
* Returns a deduplicated list of artist IDs owned by "pro" accounts
* (enterprise-domain emails or accounts with an active subscription in the
* local subscriptions mirror). Admin-scoped the response is the
* paying-customer list.
*
* `@param` request - The incoming request.
* `@returns` A NextResponse with `{ status, artists, [error] }`.
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/artists/pro/route.ts` around lines 17 - 26, The JSDoc for the GET
handler in app/api/artists/pro/route.ts is stale (mentions "active Stripe
subscriptions"); update the comment above the handler to remove Stripe
references and instead state that "pro" artists are determined from
enterprise-domain emails or the local Supabase subscriptions mirror (the code
reading from the subscriptions mirror determines active status). Keep the same
shape of the comment (purpose, params, returns) but correct the implementation
source so future readers know to look at the Supabase mirror logic used by this
route.

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) {

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Database errors are being swallowed and converted to an empty result, which can silently produce incorrect responses instead of triggering proper error handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/account_artist_ids/selectAccountArtistIds.ts, line 15:

<comment>Database errors are being swallowed and converted to an empty result, which can silently produce incorrect responses instead of triggering proper error handling.</comment>

<file context>
@@ -0,0 +1,21 @@
+    .select("artist_id")
+    .in("account_id", accountIds);
+
+  if (error) {
+    console.error("Error fetching account_artist_ids:", error);
+    return [];
</file context>
Fix with Cubic

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