Compare commits

...

1 Commits

Author SHA1 Message Date
Dhruwang dedf552001 chore: client API backwards compat — accept workspaceId or environmentId
Add resolveClientApiIds() utility that tries Environment table first,
falls back to Workspace table (production env). Updates all v1/v2
client route handlers to use the resolver.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 14:43:03 +05:30
9 changed files with 213 additions and 45 deletions
@@ -5,6 +5,7 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -21,10 +22,20 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", params.environmentId),
};
}
const { environmentId } = resolved;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
environmentId,
});
if (!inputValidation.success) {
@@ -38,7 +49,7 @@ export const POST = withV1ApiWrapper({
}
if (inputValidation.data.userId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
@@ -4,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
@@ -29,15 +30,10 @@ export const GET = withV1ApiWrapper({
};
}
const environmentId = params.environmentId.trim();
const idParam = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
// Validate CUID format
const cuidValidation = ZEnvironmentId.safeParse(idParam);
if (!cuidValidation.success) {
logger.warn(
{
@@ -45,13 +41,23 @@ export const GET = withV1ApiWrapper({
url: req.url,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID v1 format detected"
"Invalid CUID format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(idParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", idParam),
};
}
const { environmentId } = resolved;
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(environmentId);
const { data } = environmentState;
@@ -1,7 +1,6 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -13,6 +12,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -66,19 +66,16 @@ export const POST = withV1ApiWrapper({
};
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
response: responses.notFoundResponse("Environment", params.environmentId),
};
}
const { environmentId } = resolved;
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!responseInputValidation.success) {
return {
@@ -6,6 +6,7 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
@@ -29,7 +30,16 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const { environmentId } = params;
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", params.environmentId),
};
}
const { environmentId } = resolved;
let jsonInput: TUploadPrivateFileRequest;
try {
@@ -4,6 +4,7 @@ import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displ
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -25,10 +26,18 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return responses.notFoundResponse("Environment", params.environmentId);
}
const { environmentId } = resolved;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
environmentId,
});
if (!inputValidation.success) {
@@ -40,7 +49,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
if (inputValidation.data.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
@@ -1,7 +1,6 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
@@ -12,6 +11,7 @@ import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -49,17 +49,14 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return responses.notFoundResponse("Environment", params.environmentId);
}
const { environmentId } = resolved;
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!responseInputValidation.success) {
return responses.badRequestResponse(
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { resolveClientApiIds } from "./resolve-client-id";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
workspace: {
findUnique: vi.fn(),
},
},
}));
describe("resolveClientApiIds", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves an environmentId to environmentId + workspaceId", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
id: "env-123",
workspaceId: "ws-456",
} as any);
const result = await resolveClientApiIds("env-123");
expect(result).toEqual({
environmentId: "env-123",
workspaceId: "ws-456",
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: "env-123" },
select: { id: true, workspaceId: true },
});
expect(prisma.workspace.findUnique).not.toHaveBeenCalled();
});
it("resolves a workspaceId to workspaceId + production environmentId", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
id: "ws-456",
environments: [{ id: "env-prod-789" }],
} as any);
const result = await resolveClientApiIds("ws-456");
expect(result).toEqual({
environmentId: "env-prod-789",
workspaceId: "ws-456",
});
expect(prisma.workspace.findUnique).toHaveBeenCalledWith({
where: { id: "ws-456" },
select: {
id: true,
environments: {
where: { type: "production" },
select: { id: true },
take: 1,
},
},
});
});
it("returns null when neither environment nor workspace is found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(prisma.workspace.findUnique).mockResolvedValue(null);
const result = await resolveClientApiIds("unknown-id");
expect(result).toBeNull();
});
it("returns null when workspace exists but has no production environment", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
id: "ws-456",
environments: [],
} as any);
const result = await resolveClientApiIds("ws-456");
expect(result).toBeNull();
});
});
+45
View File
@@ -0,0 +1,45 @@
import "server-only";
import { prisma } from "@formbricks/database";
export type TResolvedClientIds = {
workspaceId: string;
environmentId: string;
};
/**
* Resolves a URL parameter that may be an environmentId (old SDK) or workspaceId (new SDK).
*
* - If the id matches an Environment, returns the environment's id and its parent workspaceId.
* - If not, checks the Workspace table and returns the workspace's production environment id.
* - Returns null if neither lookup succeeds.
*/
export const resolveClientApiIds = async (id: string): Promise<TResolvedClientIds | null> => {
// Try as environmentId first (existing SDKs)
const environment = await prisma.environment.findUnique({
where: { id },
select: { id: true, workspaceId: true },
});
if (environment) {
return { workspaceId: environment.workspaceId, environmentId: environment.id };
}
// Try as workspaceId (new SDKs sending workspaceId)
const workspace = await prisma.workspace.findUnique({
where: { id },
select: {
id: true,
environments: {
where: { type: "production" },
select: { id: true },
take: 1,
},
},
});
if (workspace && workspace.environments[0]) {
return { workspaceId: workspace.id, environmentId: workspace.environments[0].id };
}
return null;
};
@@ -7,6 +7,7 @@ import { TJsPersonState } from "@formbricks/types/js";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
@@ -45,15 +46,10 @@ export const POST = withV1ApiWrapper({
};
}
const environmentId = params.environmentId.trim();
const idParam = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
// Validate CUID format
const cuidValidation = ZEnvironmentId.safeParse(idParam);
if (!cuidValidation.success) {
logger.warn(
{
@@ -61,13 +57,22 @@ export const POST = withV1ApiWrapper({
url: req.url,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID v1 format detected"
"Invalid CUID format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(idParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", idParam),
};
}
const { environmentId } = resolved;
const jsonInput = await req.json();
// Basic input validation without Zod overhead