chore: client API backwards compat — accept workspaceId or environmentId (#7609)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2026-04-03 17:18:56 +05:30
committed by GitHub
parent c544bb0b22
commit 01ee015086
41 changed files with 454 additions and 226 deletions

View File

@@ -15,7 +15,7 @@ import { getOrganization } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getOrganizationIdFromWorkspaceId, getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
@@ -53,9 +53,8 @@ export const POST = async (request: Request) => {
);
}
const { environmentId, surveyId, event, response } = inputValidation.data;
const { environmentId, workspaceId, surveyId, event, response } = inputValidation.data;
const workspaceId = await getWorkspaceIdFromEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const organization = await getOrganization(organizationId);
if (!organization) {
@@ -70,19 +69,19 @@ export const POST = async (request: Request) => {
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.environmentId !== environmentId) {
if (survey.workspaceId !== workspaceId) {
logger.error(
{ url: request.url, surveyId, environmentId, surveyEnvironmentId: survey.environmentId },
`Survey ${surveyId} does not belong to environment ${environmentId}`
{ url: request.url, surveyId, workspaceId, surveyWorkspaceId: survey.workspaceId },
`Survey ${surveyId} does not belong to workspace ${workspaceId}`
);
return responses.badRequestResponse("Survey not found in this environment");
return responses.badRequestResponse("Survey not found in this workspace");
}
// Fetch webhooks
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
const getWebhooksForPipeline = async (workspaceId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId,
workspaceId,
triggers: { has: event },
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
},
@@ -90,7 +89,7 @@ export const POST = async (request: Request) => {
return webhooks;
};
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
const webhooks: Webhook[] = await getWebhooksForPipeline(workspaceId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging
@@ -165,7 +164,6 @@ export const POST = async (request: Request) => {
}
// Fetch users with notifications in a single query
// TODO: add cache for this query. Not possible at the moment since we can't get the membership cache by environmentId
const usersWithNotifications = await prisma.user.findMany({
where: {
memberships: {
@@ -173,9 +171,7 @@ export const POST = async (request: Request) => {
organization: {
workspaces: {
some: {
environments: {
some: { id: environmentId },
},
id: workspaceId,
},
},
},
@@ -198,11 +194,7 @@ export const POST = async (request: Request) => {
workspaceTeams: {
some: {
workspace: {
environments: {
some: {
id: environmentId,
},
},
id: workspaceId,
},
},
},

View File

@@ -6,6 +6,7 @@ export const ZPipelineInput = z.object({
event: ZWebhook.shape.triggers.element,
response: ZResponse,
environmentId: z.string(),
workspaceId: z.string(),
surveyId: z.string(),
});

View File

@@ -3,7 +3,7 @@ import { prisma } from "@formbricks/database";
export const getContactByUserId = reactCache(
async (
environmentId: string,
workspaceId: string,
userId: string
): Promise<{
id: string;
@@ -14,7 +14,7 @@ export const getContactByUserId = reactCache(
some: {
attributeKey: {
key: "userId",
environmentId,
workspaceId,
},
value: userId,
},

View File

@@ -3,15 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/helper", () => ({
getWorkspaceIdFromEnvironmentId: vi.fn().mockResolvedValue("workspace-id-mock"),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
@@ -36,26 +31,29 @@ vi.mock("./contact", () => ({
getContactByUserId: vi.fn(),
}));
const environmentId = "test-env-id";
const surveyId = "test-survey-id";
const environmentId = "scgavd0rtce5xgahiresk4p0";
const workspaceId = "cqiu9au22kgzgqjossdlp5qh";
const surveyId = "kf4w7x11wut39ttl4j5v9ccg";
const userId = "test-user-id";
const contactId = "test-contact-id";
const displayId = "test-display-id";
const contactId = "f9bufd72cffj19a7qj67z5fm";
const displayId = "apbycx5war0mfsyztgpwb8wr";
const displayInput: TDisplayCreateInput = {
environmentId,
workspaceId,
surveyId,
userId,
};
const displayInputWithoutUserId: TDisplayCreateInput = {
environmentId,
workspaceId,
surveyId,
};
const mockContact = {
id: contactId,
environmentId,
workspaceId,
userId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -88,7 +86,9 @@ const mockSurvey = {
describe("createDisplay", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getWorkspaceIdFromEnvironmentId).mockResolvedValue("workspace-id-mock");
vi.mocked(validateInputs).mockImplementation((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
});
@@ -99,7 +99,7 @@ describe("createDisplay", () => {
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.contact.create).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
@@ -119,11 +119,11 @@ describe("createDisplay", () => {
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.contact.create).toHaveBeenCalledWith({
data: {
environment: { connect: { id: environmentId } },
workspace: { connect: { id: "workspace-id-mock" } },
workspace: { connect: { id: workspaceId } },
attributes: {
create: {
attributeKey: {
@@ -177,9 +177,9 @@ describe("createDisplay", () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, environmentId },
where: { id: surveyId, workspaceId },
});
expect(prisma.display.create).not.toHaveBeenCalled();
});
@@ -193,7 +193,7 @@ describe("createDisplay", () => {
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.display.create).toHaveBeenCalled();
});
@@ -203,7 +203,7 @@ describe("createDisplay", () => {
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.display.create).toHaveBeenCalled();
});
@@ -212,7 +212,7 @@ describe("createDisplay", () => {
vi.mocked(getContactByUserId).mockRejectedValue(contactError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.display.create).not.toHaveBeenCalled();
});
@@ -222,7 +222,7 @@ describe("createDisplay", () => {
vi.mocked(prisma.contact.create).mockRejectedValue(contactCreateError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCreateError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.contact.create).toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});

View File

@@ -2,21 +2,19 @@ import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInput]);
const { environmentId, userId, surveyId } = displayInput;
const { environmentId, workspaceId, userId, surveyId } = displayInput;
try {
let contact: { id: string } | null = null;
if (userId) {
contact = await getContactByUserId(environmentId, userId);
contact = await getContactByUserId(workspaceId, userId);
if (!contact) {
const workspaceId = await getWorkspaceIdFromEnvironmentId(environmentId);
contact = await prisma.contact.create({
data: {
environment: { connect: { id: environmentId } },
@@ -37,7 +35,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
const survey = await prisma.survey.findUnique({
where: {
id: surveyId,
environmentId,
workspaceId,
},
});
if (!survey) {

View File

@@ -4,7 +4,8 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
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 { getOrganizationIdFromWorkspaceId } 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,21 @@ 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, workspaceId } = resolved;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
environmentId,
workspaceId,
});
if (!inputValidation.success) {
@@ -38,7 +50,7 @@ export const POST = withV1ApiWrapper({
}
if (inputValidation.data.userId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {

View File

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

View File

@@ -206,6 +206,7 @@ export const PUT = withV1ApiWrapper({
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
workspaceId: survey.workspaceId,
surveyId: survey.id,
response: responseData,
});
@@ -216,6 +217,7 @@ export const PUT = withV1ApiWrapper({
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
workspaceId: survey.workspaceId,
surveyId: survey.id,
response: responseData,
});

View File

@@ -102,7 +102,7 @@ describe("Contact API Lib", () => {
some: {
attributeKey: {
key: "userId",
environmentId: mockEnvironmentId,
workspaceId: mockEnvironmentId,
},
value: mockUserId,
},
@@ -138,7 +138,7 @@ describe("Contact API Lib", () => {
some: {
attributeKey: {
key: "userId",
environmentId: mockEnvironmentId,
workspaceId: mockEnvironmentId,
},
value: mockUserId,
},

View File

@@ -21,7 +21,7 @@ export const getContact = reactCache(async (contactId: string) => {
export const getContactByUserId = reactCache(
async (
environmentId: string,
workspaceId: string,
userId: string
): Promise<{
id: string;
@@ -33,7 +33,7 @@ export const getContactByUserId = reactCache(
some: {
attributeKey: {
key: "userId",
environmentId,
workspaceId,
},
value: userId,
},

View File

@@ -4,8 +4,9 @@ import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
@@ -19,7 +20,11 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
getOrganization: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({
@@ -54,6 +59,7 @@ vi.mock("@/modules/ee/quotas/lib/evaluation-service", () => ({
}));
const environmentId = "test-environment-id";
const workspaceId = "test-workspace-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
const responseId = "test-response-id";
@@ -68,6 +74,7 @@ const mockOrganization = {
const mockResponseInput: TResponseInput = {
environmentId,
workspaceId,
surveyId,
userId: null,
finished: false,
@@ -103,7 +110,8 @@ let mockTx: MockTx;
describe("createResponse", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(organizationId);
vi.mocked(getOrganization).mockResolvedValue(mockOrganization as any);
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc);
});
@@ -124,7 +132,7 @@ describe("createResponse", () => {
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
vi.mocked(getOrganization).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
});
@@ -153,7 +161,8 @@ describe("createResponseWithQuotaEvaluation", () => {
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(organizationId);
vi.mocked(getOrganization).mockResolvedValue(mockOrganization as any);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc);
});

View File

@@ -7,8 +7,9 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact";
@@ -83,18 +84,19 @@ export const createResponse = async (
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
const { workspaceId, userId, finished, ttc: initialTtc } = responseInput;
try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
const organization = await getOrganizationByEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", environmentId);
throw new ResourceNotFoundError("Organization", organizationId);
}
if (userId) {
contact = await getContactByUserId(environmentId, userId);
contact = await getContactByUserId(workspaceId, userId);
}
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};

View File

@@ -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";
@@ -12,7 +11,8 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
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 { getOrganizationIdFromWorkspaceId } 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,20 @@ 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, workspaceId } = resolved;
const responseInputValidation = ZResponseInput.safeParse({
...responseInput,
environmentId,
workspaceId,
});
if (!responseInputValidation.success) {
return {
@@ -102,7 +103,7 @@ export const POST = withV1ApiWrapper({
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
@@ -121,13 +122,13 @@ export const POST = withV1ApiWrapper({
response: responses.notFoundResponse("Survey", responseInputData.surveyId, true),
};
}
if (survey.environmentId !== environmentId) {
if (survey.workspaceId !== workspaceId) {
return {
response: responses.badRequestResponse(
"Survey is part of another environment",
"Survey is part of another workspace",
{
"survey.environmentId": survey.environmentId,
environmentId,
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
),
@@ -190,6 +191,7 @@ export const POST = withV1ApiWrapper({
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
workspaceId,
surveyId: responseData.surveyId,
response: responseData,
});
@@ -198,6 +200,7 @@ export const POST = withV1ApiWrapper({
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
workspaceId,
surveyId: responseData.surveyId,
response: responseData,
});

View File

@@ -4,8 +4,10 @@ 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 { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getOrganization } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
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 +31,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, workspaceId } = resolved;
let jsonInput: TUploadPrivateFileRequest;
try {
@@ -44,6 +55,7 @@ export const POST = withV1ApiWrapper({
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
...jsonInput,
environmentId,
workspaceId,
});
if (!parsedInputResult.success) {
@@ -62,10 +74,11 @@ export const POST = withV1ApiWrapper({
const { fileName, fileType, surveyId } = parsedInputResult.data;
const [survey, organization] = await Promise.all([
const [survey, organizationId] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
getOrganizationIdFromWorkspaceId(workspaceId),
]);
const organization = await getOrganization(organizationId);
if (!survey) {
return {
@@ -79,11 +92,11 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.environmentId !== environmentId) {
if (survey.workspaceId !== workspaceId) {
return {
response: responses.badRequestResponse(
"Survey does not belong to the environment",
{ surveyId, environmentId },
"Survey does not belong to the workspace",
{ surveyId, workspaceId },
true
),
};

View File

@@ -172,6 +172,7 @@ export const PUT = withV1ApiWrapper({
sendToPipeline({
event: "responseUpdated",
environmentId: result.survey.environmentId,
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
response: updated,
});
@@ -180,6 +181,7 @@ export const PUT = withV1ApiWrapper({
sendToPipeline({
event: "responseFinished",
environmentId: result.survey.environmentId,
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
response: updated,
});

View File

@@ -100,7 +100,7 @@ export const createResponse = async (
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", environmentId);
throw new ResourceNotFoundError("Organization", null);
}
if (userId) {

View File

@@ -177,6 +177,7 @@ export const POST = withV1ApiWrapper({
sendToPipeline({
event: "responseCreated",
environmentId: surveyResult.survey.environmentId,
workspaceId: surveyResult.survey.workspaceId,
surveyId: response.surveyId,
response: response,
});
@@ -185,6 +186,7 @@ export const POST = withV1ApiWrapper({
sendToPipeline({
event: "responseFinished",
environmentId: surveyResult.survey.environmentId,
workspaceId: surveyResult.survey.workspaceId,
surveyId: response.surveyId,
response: response,
});

View File

@@ -29,18 +29,21 @@ vi.mock("./contact", () => ({
}));
const environmentId = "test-env-id";
const workspaceId = "workspace-id-mock";
const surveyId = "test-survey-id";
const contactId = "test-contact-id";
const displayId = "test-display-id";
const displayInput: TDisplayCreateInputV2 = {
environmentId,
workspaceId,
surveyId,
contactId,
};
const displayInputWithoutContact: TDisplayCreateInputV2 = {
environmentId,
workspaceId,
surveyId,
};
@@ -144,7 +147,7 @@ describe("createDisplay", () => {
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, environmentId },
where: { id: surveyId, workspaceId },
});
expect(prisma.display.create).not.toHaveBeenCalled();
});

View File

@@ -11,7 +11,7 @@ import { doesContactExist } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
const { contactId, surveyId, environmentId } = displayInput;
const { contactId, surveyId, workspaceId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
@@ -19,7 +19,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
const survey = await prisma.survey.findUnique({
where: {
id: surveyId,
environmentId,
workspaceId,
},
});
if (!survey) {

View File

@@ -3,7 +3,8 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromWorkspaceId } 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,19 @@ 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, workspaceId } = resolved;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
environmentId,
workspaceId,
});
if (!inputValidation.success) {
@@ -40,7 +50,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
if (inputValidation.data.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);

View File

@@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { getOrganizationBillingByEnvironmentId } from "./organization";
import { getOrganizationBillingByWorkspaceId } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -17,8 +17,8 @@ vi.mock("@formbricks/logger", () => ({
},
}));
describe("getOrganizationBillingByEnvironmentId", () => {
const environmentId = "env-123";
describe("getOrganizationBillingByWorkspaceId", () => {
const workspaceId = "ws-123";
const mockBillingData: TOrganizationBilling = {
limits: {
monthly: { responses: 0 },
@@ -30,17 +30,13 @@ describe("getOrganizationBillingByEnvironmentId", () => {
test("returns billing when organization is found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: mockBillingData } as any);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
const result = await getOrganizationBillingByWorkspaceId(workspaceId);
expect(result).toEqual(mockBillingData);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: {
workspaces: {
some: {
environments: {
some: {
id: environmentId,
},
},
id: workspaceId,
},
},
},
@@ -59,15 +55,15 @@ describe("getOrganizationBillingByEnvironmentId", () => {
test("returns null when organization is not found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce(null);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
const result = await getOrganizationBillingByWorkspaceId(workspaceId);
expect(result).toBeNull();
});
test("logs error and returns null on exception", async () => {
const error = new Error("db error");
vi.mocked(prisma.organization.findFirst).mockRejectedValueOnce(error);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
const result = await getOrganizationBillingByWorkspaceId(workspaceId);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(error, "Failed to get organization billing by environment ID");
expect(logger.error).toHaveBeenCalledWith(error, "Failed to get organization billing by workspace ID");
});
});

View File

@@ -3,18 +3,14 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TOrganizationBilling } from "@formbricks/types/organizations";
export const getOrganizationBillingByEnvironmentId = reactCache(
async (environmentId: string): Promise<TOrganizationBilling | null> => {
export const getOrganizationBillingByWorkspaceId = reactCache(
async (workspaceId: string): Promise<TOrganizationBilling | null> => {
try {
const organization = await prisma.organization.findFirst({
where: {
workspaces: {
some: {
environments: {
some: {
id: environmentId,
},
},
id: workspaceId,
},
},
},
@@ -43,7 +39,7 @@ export const getOrganizationBillingByEnvironmentId = reactCache(
: { stripe: organization.billing.stripe as TOrganizationBilling["stripe"] }),
};
} catch (error) {
logger.error(error, "Failed to get organization billing by environment ID");
logger.error(error, "Failed to get organization billing by workspace ID");
return null;
}
}

View File

@@ -7,8 +7,9 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";
@@ -45,6 +46,7 @@ vi.mock("@/lib/constants", () => ({
vi.mock("@/lib/organization/service");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/utils/helper");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({
@@ -58,6 +60,7 @@ vi.mock("@formbricks/logger");
vi.mock("./contact");
const environmentId = "test-environment-id";
const workspaceId = "test-workspace-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
const responseId = "test-response-id";
@@ -79,6 +82,7 @@ const mockContact: { id: string; attributes: TContactAttributes } = {
const mockResponseInput: TResponseInputV2 = {
environmentId,
workspaceId,
surveyId,
contactId: null,
displayId: null,
@@ -151,7 +155,8 @@ describe("createResponse V2", () => {
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
vi.mocked(validateInputs).mockImplementation((() => {}) as typeof validateInputs);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(organizationId);
vi.mocked(getOrganization).mockResolvedValue(mockOrganization as any);
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({
@@ -169,7 +174,7 @@ describe("createResponse V2", () => {
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
vi.mocked(getOrganization).mockResolvedValue(null);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(ResourceNotFoundError);
@@ -199,6 +204,7 @@ describe("createResponse V2", () => {
id: "tag1",
name: "Tag 1",
environmentId,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -223,7 +229,8 @@ describe("createResponseWithQuotaEvaluation V2", () => {
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(organizationId);
vi.mocked(getOrganization).mockResolvedValue(mockOrganization as any);
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({

View File

@@ -8,8 +8,9 @@ import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";
@@ -90,14 +91,15 @@ export const createResponse = async (
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
const { workspaceId, contactId, finished, ttc: initialTtc } = responseInput;
try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
const organization = await getOrganizationByEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", environmentId);
throw new ResourceNotFoundError("Organization", null);
}
if (contactId) {

View File

@@ -2,13 +2,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { getOrganizationBillingByWorkspaceId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
vi.mock("@/lib/i18n/utils", () => ({
@@ -33,11 +33,11 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({
getOrganizationBillingByEnvironmentId: vi.fn(),
getOrganizationBillingByWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
@@ -97,6 +97,7 @@ const mockSurvey: TSurvey = {
const mockResponseInput: TResponseInputV2 = {
surveyId: "survey-1",
environmentId: "env-1",
workspaceId: "ws-1",
data: {},
finished: false,
ttc: {},
@@ -115,37 +116,37 @@ const mockBillingData: TOrganizationBilling = {
describe("checkSurveyValidity", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("cm8f4x9mm0001gx9h5b7d7h3q");
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("cm8f4x9mm0001gx9h5b7d7h3q");
});
test("should return badRequestResponse if survey environmentId does not match", async () => {
const survey = { ...mockSurvey, environmentId: "env-2" };
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
test("should return badRequestResponse if survey workspaceId does not match", async () => {
const survey = { ...mockSurvey, workspaceId: "ws-2" };
const result = await checkSurveyValidity(survey, "ws-1", mockResponseInput);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another environment",
"Survey is part of another workspace",
{
"survey.environmentId": "env-2",
environmentId: "env-1",
"survey.workspaceId": "ws-2",
workspaceId: "ws-1",
},
true
);
});
test("should return null if recaptcha is not enabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 } };
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 }, workspaceId: "ws-1" };
const result = await checkSurveyValidity(survey, "ws-1", mockResponseInput);
expect(result).toBeNull();
});
test("should return badRequestResponse if recaptcha enabled, spam protection enabled, but token is missing", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 }, workspaceId: "ws-1" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
const responseInputWithoutToken = { ...mockResponseInput };
delete responseInputWithoutToken.recaptchaToken;
const result = await checkSurveyValidity(survey, "env-1", responseInputWithoutToken);
const result = await checkSurveyValidity(survey, "ws-1", responseInputWithoutToken);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(logger.error).toHaveBeenCalledWith("Missing recaptcha token");
@@ -157,26 +158,26 @@ describe("checkSurveyValidity", () => {
});
test("should return not found response if billing data is not found", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 }, workspaceId: "ws-1" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(null);
vi.mocked(getOrganizationBillingByWorkspaceId).mockResolvedValue(null);
const result = await checkSurveyValidity(survey, "env-1", {
const result = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
recaptchaToken: "test-token",
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(404);
expect(responses.notFoundResponse).toHaveBeenCalledWith("Organization", null);
expect(getOrganizationBillingByEnvironmentId).toHaveBeenCalledWith("env-1");
expect(getOrganizationBillingByWorkspaceId).toHaveBeenCalledWith("ws-1");
});
test("should return null if recaptcha is enabled but spam protection is disabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 }, workspaceId: "ws-1" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", {
vi.mocked(getOrganizationBillingByWorkspaceId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
recaptchaToken: "test-token",
});
@@ -185,13 +186,13 @@ describe("checkSurveyValidity", () => {
});
test("should return badRequestResponse if recaptcha verification fails", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 }, workspaceId: "ws-1" };
const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(false);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
vi.mocked(getOrganizationBillingByWorkspaceId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken);
const result = await checkSurveyValidity(survey, "ws-1", responseInputWithToken);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(verifyRecaptchaToken).toHaveBeenCalledWith("test-token", 0.5);
@@ -203,37 +204,37 @@ describe("checkSurveyValidity", () => {
});
test("should return null if recaptcha verification passes", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 }, workspaceId: "ws-1" };
const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
vi.mocked(getOrganizationBillingByWorkspaceId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken);
const result = await checkSurveyValidity(survey, "ws-1", responseInputWithToken);
expect(result).toBeNull();
expect(verifyRecaptchaToken).toHaveBeenCalledWith("test-token", 0.5);
});
test("should return null for a valid survey and input", async () => {
const survey = { ...mockSurvey }; // Recaptcha disabled by default in mock
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
const survey = { ...mockSurvey, workspaceId: "ws-1" }; // Recaptcha disabled by default in mock
const result = await checkSurveyValidity(survey, "ws-1", mockResponseInput);
expect(result).toBeNull();
});
test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const result = await checkSurveyValidity(survey, "ws-1", { ...mockResponseInput });
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
workspaceId: "ws-1",
});
});
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const result = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: {},
@@ -242,13 +243,13 @@ describe("checkSurveyValidity", () => {
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId: "env-1",
workspaceId: "ws-1",
});
});
test("should return badRequestResponse if meta.url is invalid", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const result = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url: "not-a-url" },
@@ -257,14 +258,14 @@ describe("checkSurveyValidity", () => {
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid URL in response metadata",
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
expect.objectContaining({ surveyId: survey.id, workspaceId: "ws-1" })
);
});
test("should return badRequestResponse if suId is missing from url", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const url = "https://example.com/?foo=bar";
const result = await checkSurveyValidity(survey, "env-1", {
const result = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
@@ -273,15 +274,15 @@ describe("checkSurveyValidity", () => {
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
workspaceId: "ws-1",
});
});
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true }, workspaceId: "ws-1" };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
const resultEncryptedMismatch = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
@@ -291,14 +292,14 @@ describe("checkSurveyValidity", () => {
expect(resultEncryptedMismatch?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
workspaceId: "ws-1",
});
});
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const url = "https://example.com/?suId=su-2";
const result = await checkSurveyValidity(survey, "env-1", {
const result = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
@@ -307,14 +308,14 @@ describe("checkSurveyValidity", () => {
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
workspaceId: "ws-1",
});
});
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "env-1", {
const result = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
@@ -323,10 +324,10 @@ describe("checkSurveyValidity", () => {
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true }, workspaceId: "ws-1" };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
const _resultEncryptedMatch = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },

View File

@@ -1,27 +1,27 @@
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { getOrganizationBillingByWorkspaceId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed";
export const checkSurveyValidity = async (
survey: TSurvey,
environmentId: string,
workspaceId: string,
responseInput: TResponseInputV2
): Promise<Response | null> => {
if (survey.environmentId !== environmentId) {
if (survey.workspaceId !== workspaceId) {
return responses.badRequestResponse(
"Survey is part of another environment",
"Survey is part of another workspace",
{
"survey.environmentId": survey.environmentId,
environmentId,
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
);
@@ -31,14 +31,14 @@ export const checkSurveyValidity = async (
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
workspaceId,
});
}
if (!responseInput.meta?.url) {
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
workspaceId,
});
}
@@ -48,7 +48,7 @@ export const checkSurveyValidity = async (
} catch (error) {
return responses.badRequestResponse("Invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
workspaceId,
error: error instanceof Error ? error.message : "Unknown error occurred",
});
}
@@ -56,7 +56,7 @@ export const checkSurveyValidity = async (
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
workspaceId,
});
}
@@ -65,13 +65,13 @@ export const checkSurveyValidity = async (
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
workspaceId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
workspaceId,
});
}
}
@@ -87,13 +87,13 @@ export const checkSurveyValidity = async (
true
);
}
const billing = await getOrganizationBillingByEnvironmentId(environmentId);
const billing = await getOrganizationBillingByWorkspaceId(workspaceId);
if (!billing) {
return responses.notFoundResponse("Organization", null);
}
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organizationId);
if (!isSpamProtectionEnabled) {

View File

@@ -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";
@@ -11,7 +10,8 @@ import { sendToPipeline } from "@/app/lib/pipelines";
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 { getOrganizationIdFromWorkspaceId } 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,18 @@ 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, workspaceId } = resolved;
const responseInputValidation = ZResponseInputV2.safeParse({
...responseInput,
environmentId,
workspaceId,
});
if (!responseInputValidation.success) {
return responses.badRequestResponse(
@@ -81,7 +82,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
const responseInputData = responseInputValidation.data;
if (responseInputData.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
@@ -93,7 +94,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
const surveyCheckResult = await checkSurveyValidity(survey, workspaceId, responseInput);
if (surveyCheckResult) return surveyCheckResult;
// Validate response data for "other" options exceeding character limit
@@ -168,6 +169,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
sendToPipeline({
event: "responseCreated",
environmentId,
workspaceId,
surveyId: responseData.surveyId,
response: responseData,
});
@@ -176,6 +178,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
sendToPipeline({
event: "responseFinished",
environmentId,
workspaceId,
surveyId: responseData.surveyId,
response: responseData,
});

View File

@@ -45,6 +45,7 @@ describe("pipelines", () => {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
environmentId: "cm8cmp9hp000008jf7l570ml2",
workspaceId: "cm8cnq2hp000008jf7l570abc",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
@@ -61,6 +62,7 @@ describe("pipelines", () => {
},
body: JSON.stringify({
environmentId: testData.environmentId,
workspaceId: testData.workspaceId,
surveyId: testData.surveyId,
event: testData.event,
response: testData.response,
@@ -78,6 +80,7 @@ describe("pipelines", () => {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
environmentId: "cm8cmp9hp000008jf7l570ml2",
workspaceId: "cm8cnq2hp000008jf7l570abc",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
@@ -104,6 +107,7 @@ describe("pipelines", () => {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
environmentId: "cm8cmp9hp000008jf7l570ml2",
workspaceId: "cm8cnq2hp000008jf7l570abc",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};

View File

@@ -2,7 +2,13 @@ import { logger } from "@formbricks/logger";
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => {
export const sendToPipeline = async ({
event,
surveyId,
environmentId,
workspaceId,
response,
}: TPipelineInput) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
}
@@ -14,8 +20,9 @@ export const sendToPipeline = async ({ event, surveyId, environmentId, response
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
environmentId: environmentId,
surveyId: surveyId,
environmentId,
workspaceId,
surveyId,
event,
response,
}),

View File

@@ -5,5 +5,6 @@ export interface TPipelineInput {
event: PipelineTriggers;
response: TResponse;
environmentId: string;
workspaceId: string;
surveyId: string;
}

View File

@@ -32,6 +32,7 @@ export const mockDisplayWithResponseId = createMockDisplay({
export const mockDisplayInput = {
environmentId: mockEnvironmentId,
workspaceId: mockId,
surveyId: mockSurveyId,
};
export const mockDisplayInputWithUserId = {

View File

@@ -14,6 +14,7 @@ type ResponseMock = Prisma.ResponseGetPayload<{
}>;
export const mockEnvironmentId = "ars2tjk8hsi8oqk1uac00mo7";
export const mockWorkspaceId = "wksp2tjk8hsi8oqk1uac00mo";
export const mockContactId = "lhwy39ga2zy8by1ol1bnaiso";
export const mockResponseId = "z32bqib0nlcw8vqymlj6m8x7";
export const mockSingleUseId = "qj57j3opsw8b5sxgea20fgcq";
@@ -48,6 +49,7 @@ export const mockTags = [
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
workspaceId: mockWorkspaceId,
},
},
];
@@ -117,6 +119,7 @@ const getMockTags = (tags: string[]): { tag: TTag }[] => {
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
workspaceId: mockWorkspaceId,
},
}));
};

View File

@@ -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();
});
});

View File

@@ -0,0 +1,46 @@
import "server-only";
import { cache as reactCache } from "react";
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 = reactCache(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?.environments[0]) {
return { workspaceId: workspace.id, environmentId: workspace.environments[0].id };
}
return null;
});

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
@@ -224,9 +225,12 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
// Fetch updated response with relations for pipeline
const updatedResponseForPipeline = await getResponseForPipeline(params.responseId);
if (updatedResponseForPipeline.ok) {
const workspaceId = await getWorkspaceIdFromEnvironmentId(environmentIdResult.data);
sendToPipeline({
event: "responseUpdated",
environmentId: environmentIdResult.data,
workspaceId,
surveyId: existingResponse.data.surveyId,
response: updatedResponseForPipeline.data,
});
@@ -235,6 +239,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
sendToPipeline({
event: "responseFinished",
environmentId: environmentIdResult.data,
workspaceId,
surveyId: existingResponse.data.surveyId,
response: updatedResponseForPipeline.data,
});

View File

@@ -1,6 +1,7 @@
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
@@ -158,9 +159,12 @@ export const POST = async (request: Request) =>
// Fetch created response with relations for pipeline
const createdResponseForPipeline = await getResponseForPipeline(createResponseResult.data.id);
if (createdResponseForPipeline.ok) {
const workspaceId = await getWorkspaceIdFromEnvironmentId(environmentId);
sendToPipeline({
event: "responseCreated",
environmentId: environmentId,
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
@@ -169,6 +173,7 @@ export const POST = async (request: Request) =>
sendToPipeline({
event: "responseFinished",
environmentId: environmentId,
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});

View File

@@ -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

View File

@@ -34,7 +34,7 @@ export class ApiClient {
}
async createDisplay(
displayInput: Omit<TDisplayCreateInput, "environmentId"> & { contactId?: string }
displayInput: Omit<TDisplayCreateInput, "environmentId" | "workspaceId"> & { contactId?: string }
): Promise<Result<{ id: string }, ApiErrorResponse>> {
const fromV1 = !!displayInput.userId;
@@ -47,7 +47,7 @@ export class ApiClient {
}
async createResponse(
responseInput: Omit<TResponseInput, "environmentId"> & {
responseInput: Omit<TResponseInput, "environmentId" | "workspaceId"> & {
contactId: string | null;
recaptchaToken?: string;
}

View File

@@ -12,6 +12,7 @@ export type TDisplay = z.infer<typeof ZDisplay>;
export const ZDisplayCreateInput = z.object({
environmentId: z.cuid2(),
workspaceId: z.cuid2(),
surveyId: z.cuid2(),
userId: z
.string()

View File

@@ -343,6 +343,7 @@ export const ZResponseInput = z.object({
createdAt: z.coerce.date().optional(),
updatedAt: z.coerce.date().optional(),
environmentId: z.cuid2(),
workspaceId: z.cuid2(),
surveyId: z.cuid2(),
userId: z.string().nullish(),
displayId: z.string().nullish(),

View File

@@ -109,6 +109,7 @@ export const ZUploadPrivateFileRequest = z
allowedFileExtensions: z.array(ZAllowedFileExtension).optional(),
surveyId: z.cuid2(),
environmentId: z.cuid2(),
workspaceId: z.cuid2(),
})
.superRefine((data, ctx) => {
refineFileUploadInput({