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
10 changes: 7 additions & 3 deletions app/api/organizations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ export async function OPTIONS() {
* GET /api/organizations
*
* Retrieves all organizations an account belongs to.
* Requires authentication via x-api-key or Authorization bearer token.
*
* For personal keys: returns the key owner's organizations.
* For org keys: returns organizations for all accounts in the org.
* For Recoup admin: returns all organizations.
*
* Query parameters:
* - accountId (required): The account's ID (UUID)
* - account_id (optional): Filter to a specific account (org keys only)
*
* @param request - The request object containing query parameters
* @param request - The request object
* @returns A NextResponse with organizations data
*/
export async function GET(request: NextRequest) {
Expand All @@ -45,4 +50,3 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
return createOrganizationHandler(request);
}

122 changes: 122 additions & 0 deletions lib/organizations/__tests__/buildGetOrganizationsParams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { buildGetOrganizationsParams } from "../buildGetOrganizationsParams";

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

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

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

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

it("returns accountId for personal key", async () => {
const result = await buildGetOrganizationsParams({
accountId: "personal-account-123",
orgId: null,
});

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

it("returns organizationId for org key", async () => {
const result = await buildGetOrganizationsParams({
accountId: "org-123",
orgId: "org-123",
});

expect(result).toEqual({
params: { organizationId: "org-123" },
error: null,
});
});

it("returns empty params for Recoup admin key", async () => {
const result = await buildGetOrganizationsParams({
accountId: "recoup-org-id",
orgId: "recoup-org-id",
});

expect(result).toEqual({
params: {},
error: null,
});
});

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

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

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

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

const result = await buildGetOrganizationsParams({
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 targetAccountId", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(false);

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

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

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

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

expect(canAccessAccount).toHaveBeenCalledWith({
orgId: "recoup-org-id",
targetAccountId: "any-account",
});
expect(result).toEqual({
params: { accountId: "any-account" },
error: null,
});
});
});
62 changes: 62 additions & 0 deletions lib/organizations/buildGetOrganizationsParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";
import type { GetAccountOrganizationsParams } from "@/lib/supabase/account_organization_ids/getAccountOrganizations";
import { RECOUP_ORG_ID } from "@/lib/const";

export interface BuildGetOrganizationsParamsInput {
/** 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 */
targetAccountId?: string;
}

export type BuildGetOrganizationsParamsResult =
| { params: GetAccountOrganizationsParams; error: null }
| { params: null; error: string };

/**
* Builds the parameters for getAccountOrganizations based on auth context.
*
* For personal keys: Returns accountId with the key owner's account
* For org keys: Returns organizationId for filtering by org membership
* For Recoup admin key: Returns empty params to indicate ALL records
*
* If targetAccountId is provided, validates access and returns that account.
*
* @param input - The auth context and optional filters
* @returns The params for getAccountOrganizations or an error
*/
export async function buildGetOrganizationsParams(
input: BuildGetOrganizationsParamsInput,
): Promise<BuildGetOrganizationsParamsResult> {
const { accountId, orgId, targetAccountId } = 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 }, error: null };
}

// No account_id filter - determine what to return based on key type
if (orgId === RECOUP_ORG_ID) {
// Recoup admin: return empty params to indicate ALL records
return { params: {}, error: null };
}

if (orgId) {
// Org key: return organizationId for filtering by org membership
return { params: { organizationId: orgId }, error: null };
}

// Personal key: Only return the key owner's organizations
return { params: { accountId }, error: null };
}
25 changes: 13 additions & 12 deletions lib/organizations/getOrganizationsHandler.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateOrganizationsQuery } from "@/lib/organizations/validateOrganizationsQuery";
import { validateGetOrganizationsRequest } from "@/lib/organizations/validateGetOrganizationsRequest";
import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations";
import { formatAccountOrganizations } from "@/lib/organizations/formatAccountOrganizations";

/**
* Handler for retrieving organizations for an account.
*
* Query parameters:
* - accountId (required): The account ID to get organizations for
* Authenticates via x-api-key or Authorization bearer token.
* For personal keys: returns the key owner's organizations.
* For org keys: returns organizations for all accounts in the org.
* For Recoup admin: returns all organizations.
*
* @param request - The request object containing query parameters
* Optional query parameter:
* - account_id: Filter to a specific account (org keys only)
*
* @param request - The request object
* @returns A NextResponse with organizations data
*/
export async function getOrganizationsHandler(request: NextRequest): Promise<NextResponse> {
try {
const { searchParams } = new URL(request.url);

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

const rawOrgs = await getAccountOrganizations({
accountId: validatedQuery.accountId,
});
const rawOrgs = await getAccountOrganizations(validated);
const organizations = formatAccountOrganizations(rawOrgs);

return NextResponse.json(
Expand Down
76 changes: 76 additions & 0 deletions lib/organizations/validateGetOrganizationsRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 { GetAccountOrganizationsParams } from "@/lib/supabase/account_organization_ids/getAccountOrganizations";
import { buildGetOrganizationsParams } from "./buildGetOrganizationsParams";
import { z } from "zod";

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

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

const queryResult = getOrganizationsQuerySchema.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 } = 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 buildGetOrganizationsParams({
accountId,
orgId,
targetAccountId,
});

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

return params;
}
40 changes: 0 additions & 40 deletions lib/organizations/validateOrganizationsQuery.ts

This file was deleted.

Loading