-
Notifications
You must be signed in to change notification settings - Fork 0
feat: refactor GET /api/organizations to use auth-derived accountId #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
sweetmantech
merged 1 commit into
test
from
sweetmantech/myc-4209-api-get-apiorganizations-update-account_id-similar-to
Feb 10, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 122 additions & 0 deletions
122
lib/organizations/__tests__/buildGetOrganizationsParams.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
This file was deleted.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TypeScript narrowing lost after destructuring the discriminated union.
After destructuring
{ params, error }, TypeScript can no longer correlate the two fields. Even though theif (error)guard on Line 65 returns early,paramsremains typed asGetAccountOrganizationsParams | null— so Line 75 could returnnull, which doesn't satisfy the declared return typePromise<NextResponse | GetAccountOrganizationsParams>.Avoid destructuring the result to preserve the discriminated union narrowing:
🛡️ Proposed fix
🤖 Prompt for AI Agents