Skip to content
Open
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
16 changes: 6 additions & 10 deletions app/api/artists/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,15 @@ export async function OPTIONS() {
/**
* GET /api/artists
*
* Retrieves artists with optional organization filtering.
* Retrieves artists for the authenticated account.
* Requires authentication via x-api-key header or Authorization bearer token.
*
* Query parameters:
* - accountId (required): The account's ID (UUID)
* - orgId (optional): Filter to artists in a specific organization (UUID)
* - personal (optional): Set to "true" to show only personal (non-org) artists
* - account_id (optional): Filter to a specific account (UUID). Only for org/admin keys.
* - organization_id (optional): Filter to artists in a specific organization (UUID).
* When omitted, returns only personal (non-organization) artists.
*
* Filtering behavior:
* - accountId only: Returns all artists (personal + all organizations)
* - accountId + orgId: Returns only artists in that specific organization
* - accountId + personal=true: Returns only personal artists (not in any org)
*
* @param request - The request object containing query parameters
* @param request - The request object
* @returns A NextResponse with artists data
*/
export async function GET(request: NextRequest) {
Expand Down
135 changes: 135 additions & 0 deletions lib/artists/__tests__/buildGetArtistsParams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { buildGetArtistsParams } from "../buildGetArtistsParams";

vi.mock("@/lib/organizations/canAccessAccount", () => ({
canAccessAccount: vi.fn(),
}));

vi.mock("@/lib/const", () => ({
RECOUP_ORG_ID: "recoup-org-id",
}));

import { canAccessAccount } from "@/lib/organizations/canAccessAccount";

describe("buildGetArtistsParams", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns personal artists for personal key with no filter", async () => {
const result = await buildGetArtistsParams({
accountId: "personal-123",
orgId: null,
});

expect(result).toEqual({
params: { accountId: "personal-123", orgId: null },
error: null,
});
});

it("returns personal artists for org key with no filter", async () => {
const result = await buildGetArtistsParams({
accountId: "org-account",
orgId: "org-123",
});

expect(result).toEqual({
params: { accountId: "org-account", orgId: null },
error: null,
});
});

it("returns personal artists for Recoup admin with no filter", async () => {
const result = await buildGetArtistsParams({
accountId: "recoup-admin",
orgId: "recoup-org-id",
});

expect(result).toEqual({
params: { accountId: "recoup-admin", orgId: null },
error: null,
});
});

it("returns target account when access is granted", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(true);

const result = await buildGetArtistsParams({
accountId: "org-account",
orgId: "org-123",
targetAccountId: "target-456",
});

expect(canAccessAccount).toHaveBeenCalledWith({
orgId: "org-123",
targetAccountId: "target-456",
});
expect(result).toEqual({
params: { accountId: "target-456", orgId: null },
error: null,
});
});

it("returns error when personal key tries to filter by account_id", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(false);

const result = await buildGetArtistsParams({
accountId: "personal-123",
orgId: null,
targetAccountId: "other-account",
});

expect(result).toEqual({
params: null,
error: "Personal API keys cannot filter by account_id",
});
});

it("returns error when org key lacks access to target account", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(false);

const result = await buildGetArtistsParams({
accountId: "org-account",
orgId: "org-123",
targetAccountId: "not-in-org",
});

expect(result).toEqual({
params: null,
error: "account_id is not a member of this organization",
});
});

it("returns target account for Recoup admin with filter", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(true);

const result = await buildGetArtistsParams({
accountId: "recoup-admin",
orgId: "recoup-org-id",
targetAccountId: "any-account",
});

expect(canAccessAccount).toHaveBeenCalledWith({
orgId: "recoup-org-id",
targetAccountId: "any-account",
});
expect(result).toEqual({
params: { accountId: "any-account", orgId: null },
error: null,
});
});

it("passes organizationId as orgId when provided", async () => {
const result = await buildGetArtistsParams({
accountId: "org-account",
orgId: "org-123",
organizationId: "org-uuid",
});

expect(result).toEqual({
params: { accountId: "org-account", orgId: "org-uuid" },
error: null,
});
});
});
60 changes: 60 additions & 0 deletions lib/artists/buildGetArtistsParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";
import type { GetArtistsOptions } from "@/lib/artists/getArtists";

export interface BuildGetArtistsParamsInput {
/** The authenticated account ID */
accountId: string;
/** The organization ID from the API key (null for personal keys) */
orgId: string | null;
/** Optional target account ID to filter by (account_id query param) */
targetAccountId?: string;
/** Optional organization ID to filter artists by (organization_id query param) */
organizationId?: string;
}

export type BuildGetArtistsParamsResult =
| { params: GetArtistsOptions; error: null }
| { params: null; error: string };

/**
* Builds the parameters for getArtists based on auth context.
*
* Determines which accountId to use:
* - If targetAccountId is provided, validates access via canAccessAccount
* - Otherwise, uses the auth-derived accountId
*
* Determines orgId filter:
* - If organizationId query param is provided, uses it
* - Otherwise, defaults to null (personal artists only)
*
* @param input - The auth context and optional filters
* @returns The params for getArtists or an error
*/
export async function buildGetArtistsParams(
input: BuildGetArtistsParamsInput,
): Promise<BuildGetArtistsParamsResult> {
const { accountId, orgId, targetAccountId, organizationId } = input;

// Handle account_id filter if provided
if (targetAccountId) {
const hasAccess = await canAccessAccount({ orgId, targetAccountId });
if (!hasAccess) {
return {
params: null,
error: orgId
? "account_id is not a member of this organization"
: "Personal API keys cannot filter by account_id",
};
}
return {
params: { accountId: targetAccountId, orgId: organizationId ?? null },
error: null,
};
}

// No account_id filter - use auth-derived accountId
return {
params: { accountId, orgId: organizationId ?? null },
error: null,
};
}
30 changes: 11 additions & 19 deletions lib/artists/getArtistsHandler.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateArtistsQuery } from "@/lib/artists/validateArtistsQuery";
import { validateGetArtistsRequest } from "@/lib/artists/validateGetArtistsRequest";
import { getArtists } from "@/lib/artists/getArtists";

/**
* Handler for retrieving artists with organization filtering.
* Handler for retrieving artists with authentication and organization filtering.
*
* Requires authentication via x-api-key or Authorization bearer token.
*
* Query parameters:
* - accountId (required): The account's ID
* - orgId (optional): Filter to artists in a specific organization
* - personal (optional): Set to "true" to show only personal (non-org) artists
* - account_id (optional): Filter to a specific account (org/admin keys only)
* - organization_id (optional): Filter to artists in a specific organization
*
* @param request - The request object containing query parameters
* @param request - The request object
* @returns A NextResponse with artists data
*/
export async function getArtistsHandler(request: NextRequest): Promise<NextResponse> {
try {
const { searchParams } = new URL(request.url);

const validatedQuery = validateArtistsQuery(searchParams);
if (validatedQuery instanceof NextResponse) {
return validatedQuery;
const validated = await validateGetArtistsRequest(request);
if (validated instanceof NextResponse) {
return validated;
}

// Determine orgId filter: personal=true means null, orgId means specific org
const orgIdFilter = validatedQuery.personal === "true" ? null : validatedQuery.orgId;

const artists = await getArtists({
accountId: validatedQuery.accountId,
orgId: orgIdFilter,
});
const artists = await getArtists(validated);

return NextResponse.json(
{
Expand All @@ -55,4 +48,3 @@ export async function getArtistsHandler(request: NextRequest): Promise<NextRespo
);
}
}

40 changes: 0 additions & 40 deletions lib/artists/validateArtistsQuery.ts

This file was deleted.

79 changes: 79 additions & 0 deletions lib/artists/validateGetArtistsRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import type { GetArtistsOptions } from "@/lib/artists/getArtists";
import { buildGetArtistsParams } from "./buildGetArtistsParams";
import { z } from "zod";

const getArtistsQuerySchema = z.object({
account_id: z.string().uuid("account_id must be a valid UUID").optional(),
organization_id: z.string().uuid("organization_id must be a valid UUID").optional(),
});

/**
* Validates GET /api/artists request.
* Handles authentication via x-api-key or Authorization bearer token.
*
* For personal keys: Returns the key owner's personal artists
* For org keys: Returns the key owner's personal artists (use organization_id for org artists)
*
* Query parameters:
* - account_id: Filter to a specific account (validated against org membership)
* - organization_id: Filter to artists in a specific organization
*
* @param request - The NextRequest object
* @returns A NextResponse with an error if validation fails, or GetArtistsOptions
*/
export async function validateGetArtistsRequest(
request: NextRequest,
): Promise<NextResponse | GetArtistsOptions> {
// Parse query parameters first
const { searchParams } = new URL(request.url);
const queryParams = {
account_id: searchParams.get("account_id") ?? undefined,
organization_id: searchParams.get("organization_id") ?? undefined,
};

const queryResult = getArtistsQuerySchema.safeParse(queryParams);
if (!queryResult.success) {
const firstError = queryResult.error.issues[0];
return NextResponse.json(
{
status: "error",
error: firstError.message,
},
{ status: 400, headers: getCorsHeaders() },
);
}

const { account_id: targetAccountId, organization_id: organizationId } = queryResult.data;

// Use validateAuthContext for authentication
const authResult = await validateAuthContext(request);
if (authResult instanceof NextResponse) {
return authResult;
}

const { accountId, orgId } = authResult;

// Use shared function to build params
const { params, error } = await buildGetArtistsParams({
accountId,
orgId,
targetAccountId,
organizationId,
});

if (error) {
return NextResponse.json(
{
status: "error",
error,
},
{ status: 403, headers: getCorsHeaders() },
);
}

return params;
}