Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { Prisma } from "@formbricks/database/prisma";
import { DatabaseError, InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { handleClientResponseCreateError } from "./response-error";

// Real Prisma 7 + adapter-pg P2002 shape (no meta.target; columns nested under the driver adapter).
const uniqueViolation = (fields: string[]): Prisma.PrismaClientKnownRequestError =>
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { driverAdapterError: { cause: { constraint: { fields } } } },
});

describe("handleClientResponseCreateError", () => {
test("maps a displayId unique violation to InvalidInputError (with the display id)", () => {
expect(() => handleClientResponseCreateError(uniqueViolation(["displayId"]), "disp_123")).toThrow(
new InvalidInputError("Display disp_123 is already linked to a response")
);
});

test("maps a singleUseId unique violation to UniqueConstraintError", () => {
expect(() => handleClientResponseCreateError(uniqueViolation(["surveyId", "singleUseId"]))).toThrow(
UniqueConstraintError
);
});

test("maps any other known Prisma error to DatabaseError carrying its message", () => {
const error = new Prisma.PrismaClientKnownRequestError("boom", { code: "P2025", clientVersion: "test" });
expect(() => handleClientResponseCreateError(error)).toThrow(new DatabaseError("boom"));
});

test("re-throws a non-Prisma error unchanged", () => {
const error = new Error("plain");
expect(() => handleClientResponseCreateError(error)).toThrow(error);
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import { Prisma } from "@formbricks/database/prisma";
import type { PrismaClientKnownRequestError } from "@formbricks/database/prisma";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { getUniqueConstraintFields, isUniqueConstraintError } from "@/lib/utils/prisma-constraint";

export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;

export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean =>
isUniqueConstraintError(error) && getUniqueConstraintFields(error).includes("singleUseId");

export const isDisplayIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean =>
isUniqueConstraintError(error) && getUniqueConstraintFields(error).includes("displayId");

return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
/**
* Maps a Prisma error thrown while creating a client response to its domain error. The v1 and v2
* client-response create paths share this exact mapping, so it lives here alongside the guards it
* uses. Always throws — either a mapped domain error or the original error re-thrown.
*/
export const handleClientResponseCreateError = (error: unknown, displayId?: string | null): never => {
if (isPrismaKnownRequestError(error)) {
if (isDisplayIdUniqueConstraintError(error)) {
throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
throw new DatabaseError(error.message);
}
throw error;
};
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe("createResponse", () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
meta: { driverAdapterError: { cause: { constraint: { fields: ["surveyId", "singleUseId"] } } } },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
Expand All @@ -184,7 +184,7 @@ describe("createResponse", () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
meta: { driverAdapterError: { cause: { constraint: { fields: ["displayId"] } } } },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
Expand Down
29 changes: 3 additions & 26 deletions apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,14 @@ import "server-only";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/prisma";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import {
buildClientResponse,
createResponseWithQuotaEvaluation as createClientResponseWithQuotaEvaluation,
} from "@/app/api/client/[workspaceId]/responses/lib/response";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { handleClientResponseCreateError } from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
Expand Down Expand Up @@ -118,21 +110,6 @@ export const createResponse = async (

return buildClientResponse(responsePrisma, contact);
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}

throw new DatabaseError(error.message);
}

throw error;
return handleClientResponseCreateError(error, responseInput.displayId);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe("createResponse V2", () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
meta: { driverAdapterError: { cause: { constraint: { fields: ["surveyId", "singleUseId"] } } } },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
Expand All @@ -204,7 +204,7 @@ describe("createResponse V2", () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["someOtherField"] },
meta: { driverAdapterError: { cause: { constraint: { fields: ["someOtherField"] } } } },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
Expand All @@ -216,7 +216,7 @@ describe("createResponse V2", () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
meta: { driverAdapterError: { cause: { constraint: { fields: ["displayId"] } } } },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
Expand Down
29 changes: 3 additions & 26 deletions apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,14 @@ import "server-only";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/prisma";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import {
buildClientResponse,
createResponseWithQuotaEvaluation as createClientResponseWithQuotaEvaluation,
} from "@/app/api/client/[workspaceId]/responses/lib/response";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { handleClientResponseCreateError } from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { buildPrismaResponseData as buildV1PrismaResponseData } from "@/app/api/v1/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
Expand Down Expand Up @@ -92,21 +84,6 @@ export const createResponse = async (

return buildClientResponse(responsePrisma, contact);
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}

throw new DatabaseError(error.message);
}

throw error;
return handleClientResponseCreateError(error, responseInput.displayId);
}
};
46 changes: 29 additions & 17 deletions apps/web/app/api/v3/surveys/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,43 +405,55 @@ describe("ZV3CreateSurveyBody", () => {
}
});

test("requires displayPercentage when displayOption is displaySome", () => {
test("accepts displaySome with displayLimit (show at most N times)", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
type: "app",
distribution: { displayOption: "displaySome" },
distribution: { displayOption: "displaySome", displayLimit: 3 },
});

expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.distribution).toMatchObject({ displayOption: "displaySome", displayLimit: 3 });
}
});

test("requires displayLimit >= 1 when displayOption is displaySome", () => {
const noLimit = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
type: "app",
distribution: { displayOption: "displaySome" },
});
expect(noLimit.success).toBe(false);
if (!noLimit.success) {
expect(formatV3ZodInvalidParams(noLimit.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "distribution.displayPercentage",
name: "distribution.displayLimit",
code: "missing_required_field",
}),
])
);
}

const zeroLimit = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
type: "app",
distribution: { displayOption: "displaySome", displayLimit: 0 },
});
expect(zeroLimit.success).toBe(false);
});

test("rejects displayPercentage when displayOption is not displaySome", () => {
test("accepts displayPercentage with any displayOption (independent throttle)", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
type: "app",
distribution: { displayOption: "displayOnce", displayPercentage: 50 },
});

expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "distribution.displayPercentage",
code: "unsupported_field",
}),
])
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.distribution).toMatchObject({ displayOption: "displayOnce", displayPercentage: 50 });
}
});

Expand Down
36 changes: 16 additions & 20 deletions apps/web/app/api/v3/surveys/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1118,9 +1118,11 @@ const ZV3SurveyDistribution = z
const ZV3SurveyTargeting = z.object({ filters: ZSegmentFilters }).strict();

/**
* Body-level validation for app-survey distribution/targeting. `distribution`/`targeting` are
* rejected for explicit `link` surveys; for `app` (or when the type is unknown, e.g. the generic
* patch schema) the displayOption ↔ displayPercentage coupling is enforced.
* Body-level validation for app-survey distribution/targeting: `distribution`/`targeting` are
* app-only, so reject them on explicit `link` surveys. Within `distribution`, `displaySome` ("show a
* limited number of times") must carry a `displayLimit` >= 1 (mirrors the editor's required "show
* maximum of N times" input). `displayPercentage` is an INDEPENDENT throttle valid with ANY
* `displayOption` — never coupled to `displaySome` — matching `ZSurvey` and the js-core runtime.
*/
function addAppDistributionIssues(
survey: {
Expand Down Expand Up @@ -1150,26 +1152,20 @@ function addAppDistributionIssues(
return;
}

// "displaySome" is capped by displayLimit, so a "limited" survey must specify a real limit (>= 1),
// mirroring the editor. displayPercentage stays independent and is validated by the field schema.
const distribution = survey.distribution;
if (!distribution) {
return;
}

if (distribution.displayOption === "displaySome") {
if (distribution.displayPercentage === null || distribution.displayPercentage === undefined) {
ctx.addIssue({
code: "custom",
message: "displayPercentage is required when displayOption is 'displaySome'",
params: { code: "missing_required_field" },
path: ["distribution", "displayPercentage"],
});
}
} else if (distribution.displayPercentage !== null && distribution.displayPercentage !== undefined) {
if (
distribution?.displayOption === "displaySome" &&
(distribution.displayLimit === null ||
distribution.displayLimit === undefined ||
distribution.displayLimit < 1)
) {
ctx.addIssue({
code: "custom",
message: "displayPercentage is only allowed when displayOption is 'displaySome'",
params: { code: "unsupported_field" },
path: ["distribution", "displayPercentage"],
message: "displayLimit must be at least 1 when displayOption is 'displaySome'",
params: { code: "missing_required_field" },
path: ["distribution", "displayLimit"],
});
}
}
Expand Down
16 changes: 5 additions & 11 deletions apps/web/lib/actionClass/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import "server-only";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ActionClass, Prisma } from "@formbricks/database/prisma";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { getUniqueConstraintFields, isUniqueConstraintError } from "../utils/prisma-constraint";
import { validateInputs } from "../utils/validate";

const selectActionClass = {
Expand Down Expand Up @@ -127,11 +127,8 @@ export const createActionClass = async (actionClass: TActionClassInput): Promise

return actionClassPrisma;
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
if (isUniqueConstraintError(error)) {
const targetField = getUniqueConstraintFields(error)[0];
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
Expand Down Expand Up @@ -176,11 +173,8 @@ export const updateActionClass = async (

return result;
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
if (isUniqueConstraintError(error)) {
const targetField = getUniqueConstraintFields(error)[0];
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
Expand Down
Loading
Loading