From a6eaab908750452c5ab8479fb4d696e9f1d6b307 Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:40:35 +0000 Subject: [PATCH 1/3] fix(sso): stop requiring RFC 9207 issuer param for Azure (ENG-1800) (#8561) --- .../ee/sso/lib/better-auth-providers.test.ts | 4 ++++ .../modules/ee/sso/lib/better-auth-providers.ts | 15 ++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts index 952627a69cbc..665202cffe16 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts @@ -168,6 +168,9 @@ describe("better-auth SSO providers", () => { "https://login.microsoftonline.com/tenant-123/v2.0/.well-known/openid-configuration" ); expect(azure.scopes).toEqual(["openid", "email", "profile"]); + // ENG-1800: Entra never returns the RFC 9207 response `iss`, so validation must stay off even + // with a fixed tenant — turning it on here is exactly what caused `error=issuer_missing`. + expect(azure.requireIssuerValidation).toBe(false); }); test("Azure discovery URL falls back to the 'common' tenant when none is configured", async () => { @@ -176,6 +179,7 @@ describe("better-auth SSO providers", () => { expect(azure?.discoveryUrl).toBe( "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" ); + expect(azure?.requireIssuerValidation).toBe(false); }); test("Azure mapProfileToUser resolves the display name through its fallback chain", async () => { diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.ts index c4435c998531..d650fd0d26e9 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.ts @@ -93,11 +93,16 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE discoveryUrl: `https://login.microsoftonline.com/${AZUREAD_TENANT_ID || "common"}/v2.0/.well-known/openid-configuration`, scopes: ["openid", "email", "profile"], pkce: true, - // RFC 9207 mix-up defense (parity with OIDC). Enabled only with a fixed tenant: a - // single-tenant discovery issuer is stable and matches the token's `iss`, whereas - // multi-tenant ("common") tokens carry the caller's home-tenant issuer, which a strict - // issuer check would reject. - requireIssuerValidation: !!AZUREAD_TENANT_ID, + // Must stay false for Azure. Better Auth's issuer check reads the RFC 9207 + // authorization-RESPONSE `iss` query parameter, and Microsoft Entra does not implement + // RFC 9207 — its v2.0 metadata omits `authorization_response_iss_parameter_supported` + // and it never returns that param. So enabling this can only ever fail with + // `error=issuer_missing` (ENG-1800); it can never pass, regardless of tenant. The + // param's purpose (disambiguating which AS responded, to defend against mix-up) is + // already covered here structurally: the per-provider callback path pins the token + // endpoint to Azure's own, and PKCE (above) + state validation bind the exchange. OIDC + // (below) keeps the check on because a spec-compliant provider does return `iss`. + requireIssuerValidation: false, mapProfileToUser: (profile) => { // Capture for verify-before-link recovery; name parity with the OIDC mapping. captureSsoIdentity({ email: profile.email, providerAccountId: profile.sub }); From 9e60bf0ef8574db7bacce3bca19e6b5e7267be7a Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:14:17 +0530 Subject: [PATCH 2/3] fix(api): allow displayLimit recontact + independent displayPercentage in v3 surveys (#8537) --- apps/web/app/api/v3/surveys/schemas.test.ts | 46 ++++++++++++------- apps/web/app/api/v3/surveys/schemas.ts | 36 +++++++-------- docs/api-v3-reference/openapi.yml | 15 +++--- .../components/schemas/SurveyDistribution.yml | 20 ++++---- .../src/paths/api_v3_surveys.yml | 11 ++--- .../src/paths/api_v3_surveys_{surveyId}.yml | 4 +- 6 files changed, 71 insertions(+), 61 deletions(-) diff --git a/apps/web/app/api/v3/surveys/schemas.test.ts b/apps/web/app/api/v3/surveys/schemas.test.ts index ffb85b4da8f3..a1a689839e19 100644 --- a/apps/web/app/api/v3/surveys/schemas.test.ts +++ b/apps/web/app/api/v3/surveys/schemas.test.ts @@ -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 }); } }); diff --git a/apps/web/app/api/v3/surveys/schemas.ts b/apps/web/app/api/v3/surveys/schemas.ts index 82318a5b6a0d..f4ad40ef059a 100644 --- a/apps/web/app/api/v3/surveys/schemas.ts +++ b/apps/web/app/api/v3/surveys/schemas.ts @@ -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: { @@ -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"], }); } } diff --git a/docs/api-v3-reference/openapi.yml b/docs/api-v3-reference/openapi.yml index 69669239b1f0..db1621c28414 100644 --- a/docs/api-v3-reference/openapi.yml +++ b/docs/api-v3-reference/openapi.yml @@ -416,7 +416,7 @@ paths: $ref: '#/components/schemas/SurveyResource' '400': description: | - Bad Request — the document failed schema validation, i.e. any rule checkable from the request body alone, without consulting stored state. Covers: invalid JSON; unknown or unsupported fields (including `distribution`/`targeting` sent on a `link` survey, which are `app`-only); missing required fields; wrong types or out-of-range values; bad enum values; malformed multilingual maps; and intra-document field-combination rules — notably `displayPercentage` is required when `displayOption` is `displaySome` and forbidden otherwise. Cross-reference failures that need stored state to detect return **422** instead. Every offending field is itemized in `invalid_params`. Unknown, forbidden, or wrongly-combined fields carry `code: unsupported_field` (e.g. `distribution` on a link survey, or `displayPercentage` without `displayOption: displaySome`); omissions carry `code: missing_required_field` (e.g. `displayPercentage` when `displayOption` is `displaySome`). Bare type, range, enum, and malformed-locale-map violations report the field `name` with a human-readable `reason` and no machine `code`. + Bad Request — the document failed schema validation, i.e. any rule checkable from the request body alone, without consulting stored state. Covers: invalid JSON; unknown or unsupported fields (including `distribution`/`targeting` sent on a `link` survey, which are `app`-only); missing required fields; wrong types or out-of-range values; bad enum values; malformed multilingual maps; and intra-document field-combination rules — notably `displayLimit` is required (must be >= 1) when `displayOption` is `displaySome`. Cross-reference failures that need stored state to detect return **422** instead. Every offending field is itemized in `invalid_params`. Unknown or forbidden fields carry `code: unsupported_field` (e.g. `distribution` on a link survey); omissions carry `code: missing_required_field` (e.g. `displayLimit` when `displayOption` is `displaySome`). Bare type, range, enum, and malformed-locale-map violations report the field `name` with a human-readable `reason` and no machine `code`. content: application/problem+json: schema: @@ -1012,7 +1012,7 @@ paths: $ref: '#/components/schemas/SurveyResource' '400': description: | - Bad Request — the patch failed schema validation, i.e. any rule checkable from the request body alone, without consulting stored state. Covers: malformed JSON; an unsupported query parameter; unknown, unsupported, or immutable fields (`type` cannot be changed after creation, and `distribution`/`targeting` are `app`-only — sending them when patching a `link` survey is rejected); wrong types or out-of-range values; bad enum values; malformed multilingual maps; and intra-document field-combination rules — notably `displayPercentage` is required when `displayOption` is `displaySome` and forbidden otherwise. Cross-reference failures that need stored state to detect return **422** instead. Every offending field is itemized in `invalid_params`. Unknown, forbidden, immutable, or wrongly-combined fields carry `code: unsupported_field` (e.g. `type`, or `distribution`/`targeting` on a link survey); omissions carry `code: missing_required_field`. Bare type, range, enum, and malformed-locale-map violations report the field `name` with a human-readable `reason` and no machine `code`. + Bad Request — the patch failed schema validation, i.e. any rule checkable from the request body alone, without consulting stored state. Covers: malformed JSON; an unsupported query parameter; unknown, unsupported, or immutable fields (`type` cannot be changed after creation, and `distribution`/`targeting` are `app`-only — sending them when patching a `link` survey is rejected); wrong types or out-of-range values; bad enum values; malformed multilingual maps; and intra-document field-combination rules — notably `displayLimit` is required (must be >= 1) when `displayOption` is `displaySome`. Cross-reference failures that need stored state to detect return **422** instead. Every offending field is itemized in `invalid_params`. Unknown, forbidden, immutable, or wrongly-combined fields carry `code: unsupported_field` (e.g. `type`, or `distribution`/`targeting` on a link survey); omissions carry `code: missing_required_field`. Bare type, range, enum, and malformed-locale-map violations report the field `name` with a human-readable `reason` and no machine `code`. content: application/problem+json: schema: @@ -2572,7 +2572,7 @@ components: - displaySome default: displayOnce description: | - How often the survey may be shown to a contact. `displaySome` shows it to `displayPercentage` of contacts. + How often the survey may be shown to a contact. `displaySome` shows it up to `displayLimit` times (or until the contact responds, whichever comes first). displayPercentage: type: - number @@ -2581,14 +2581,15 @@ components: maximum: 100 default: null description: | - Percentage of contacts to show the survey to. Required when `displayOption` is `displaySome`; must be null/omitted otherwise. + Independent throttle: show the survey to only this percentage of triggered contacts. Optional and valid with any `displayOption`; null/omitted means no throttle (shown to everyone). displayLimit: type: - integer - 'null' minimum: 0 default: null - description: Maximum number of times the survey is shown to a single contact. + description: | + Maximum number of times the survey is shown to a single contact. Required (must be >= 1) when `displayOption` is `displaySome`; otherwise optional. recontactDays: type: - integer @@ -2625,8 +2626,8 @@ components: additionalProperties: false example: displayOption: displaySome - displayPercentage: 50 - displayLimit: null + displayLimit: 3 + displayPercentage: null recontactDays: 7 autoClose: null autoComplete: null diff --git a/docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml b/docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml index 45cf022a10d6..02ec3239c6a0 100644 --- a/docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml +++ b/docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml @@ -17,8 +17,8 @@ properties: - displaySome default: displayOnce description: > - How often the survey may be shown to a contact. `displaySome` shows it to `displayPercentage` - of contacts. + How often the survey may be shown to a contact. `displaySome` shows it up to `displayLimit` + times (or until the contact responds, whichever comes first). displayPercentage: type: - number @@ -27,15 +27,17 @@ properties: maximum: 100 default: null description: > - Percentage of contacts to show the survey to. Required when `displayOption` is `displaySome`; - must be null/omitted otherwise. + Independent throttle: show the survey to only this percentage of triggered contacts. Optional + and valid with any `displayOption`; null/omitted means no throttle (shown to everyone). displayLimit: type: - integer - "null" minimum: 0 default: null - description: Maximum number of times the survey is shown to a single contact. + description: > + Maximum number of times the survey is shown to a single contact. Required (must be >= 1) when + `displayOption` is `displaySome`; otherwise optional. recontactDays: type: - integer @@ -73,11 +75,11 @@ properties: removes all existing triggers. additionalProperties: false example: - # `displaySome` requires `displayPercentage` (shown to that % of contacts); any other displayOption - # requires `displayPercentage` to be null/omitted. + # `displaySome` ("show a limited number of times") is capped by `displayLimit` (must be >= 1). + # `displayPercentage` is an independent, optional throttle valid with any `displayOption`. displayOption: displaySome - displayPercentage: 50 - displayLimit: null + displayLimit: 3 + displayPercentage: null recontactDays: 7 autoClose: null autoComplete: null diff --git a/docs/api-v3-reference/src/paths/api_v3_surveys.yml b/docs/api-v3-reference/src/paths/api_v3_surveys.yml index 3e7792a21a25..6b0229247da8 100644 --- a/docs/api-v3-reference/src/paths/api_v3_surveys.yml +++ b/docs/api-v3-reference/src/paths/api_v3_surveys.yml @@ -413,12 +413,11 @@ post: unsupported fields (including `distribution`/`targeting` sent on a `link` survey, which are `app`-only); missing required fields; wrong types or out-of-range values; bad enum values; malformed multilingual maps; and intra-document field-combination rules — notably - `displayPercentage` is required when `displayOption` is `displaySome` and forbidden - otherwise. Cross-reference failures that need stored state to detect return **422** instead. - Every offending field is itemized in `invalid_params`. Unknown, forbidden, or wrongly-combined - fields carry `code: unsupported_field` (e.g. `distribution` on a link survey, or - `displayPercentage` without `displayOption: displaySome`); omissions carry - `code: missing_required_field` (e.g. `displayPercentage` when `displayOption` is `displaySome`). + `displayLimit` is required (must be >= 1) when `displayOption` is `displaySome`. + Cross-reference failures that need stored state to detect return **422** instead. + Every offending field is itemized in `invalid_params`. Unknown or forbidden fields carry + `code: unsupported_field` (e.g. `distribution` on a link survey); omissions carry + `code: missing_required_field` (e.g. `displayLimit` when `displayOption` is `displaySome`). Bare type, range, enum, and malformed-locale-map violations report the field `name` with a human-readable `reason` and no machine `code`. content: diff --git a/docs/api-v3-reference/src/paths/api_v3_surveys_{surveyId}.yml b/docs/api-v3-reference/src/paths/api_v3_surveys_{surveyId}.yml index 22e96638a6cf..8ca57702f281 100644 --- a/docs/api-v3-reference/src/paths/api_v3_surveys_{surveyId}.yml +++ b/docs/api-v3-reference/src/paths/api_v3_surveys_{surveyId}.yml @@ -295,8 +295,8 @@ patch: parameter; unknown, unsupported, or immutable fields (`type` cannot be changed after creation, and `distribution`/`targeting` are `app`-only — sending them when patching a `link` survey is rejected); wrong types or out-of-range values; bad enum values; malformed multilingual maps; - and intra-document field-combination rules — notably `displayPercentage` is required when - `displayOption` is `displaySome` and forbidden otherwise. Cross-reference failures that need + and intra-document field-combination rules — notably `displayLimit` is required (must be >= 1) + when `displayOption` is `displaySome`. Cross-reference failures that need stored state to detect return **422** instead. Every offending field is itemized in `invalid_params`. Unknown, forbidden, immutable, or wrongly-combined fields carry `code: unsupported_field` (e.g. `type`, or `distribution`/`targeting` on a link survey); From 04511a58d52dcaa8dd8035385a234f20aa8ac34d Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:58:52 +0000 Subject: [PATCH 3/3] fix: return 409 for duplicate creates under the Prisma 7 driver adapter (ENG-1801) (#8565) --- .../responses/lib/response-error.test.ts | 36 ++++++++++++ .../responses/lib/response-error.ts | 29 ++++++++-- .../responses/lib/response.test.ts | 4 +- .../[workspaceId]/responses/lib/response.ts | 29 +--------- .../responses/lib/response.test.ts | 6 +- .../[workspaceId]/responses/lib/response.ts | 29 +--------- apps/web/lib/actionClass/service.ts | 16 ++---- apps/web/lib/feedback-source/service.test.ts | 44 ++++++++++++-- apps/web/lib/feedback-source/service.ts | 12 ++-- apps/web/lib/tagOnResponse/service.test.ts | 6 +- apps/web/lib/tagOnResponse/service.ts | 13 +++-- .../prisma-constraint.integration.test.ts | 51 +++++++++++++++++ apps/web/lib/utils/prisma-constraint.test.ts | 57 +++++++++++++++++++ apps/web/lib/utils/prisma-constraint.ts | 54 ++++++++++++++++++ .../users/lib/tests/users.integration.test.ts | 34 +++++++++++ .../users/lib/tests/users.test.ts | 31 ++++++---- .../[organizationId]/users/lib/users.ts | 21 +++---- .../survey/editor/lib/action-class.test.ts | 2 +- .../modules/survey/editor/lib/action-class.ts | 11 ++-- 19 files changed, 359 insertions(+), 126 deletions(-) create mode 100644 apps/web/app/api/client/[workspaceId]/responses/lib/response-error.test.ts create mode 100644 apps/web/lib/utils/prisma-constraint.integration.test.ts create mode 100644 apps/web/lib/utils/prisma-constraint.test.ts create mode 100644 apps/web/lib/utils/prisma-constraint.ts create mode 100644 apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.integration.test.ts diff --git a/apps/web/app/api/client/[workspaceId]/responses/lib/response-error.test.ts b/apps/web/app/api/client/[workspaceId]/responses/lib/response-error.test.ts new file mode 100644 index 000000000000..40d5a11ee106 --- /dev/null +++ b/apps/web/app/api/client/[workspaceId]/responses/lib/response-error.test.ts @@ -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); + }); +}); diff --git a/apps/web/app/api/client/[workspaceId]/responses/lib/response-error.ts b/apps/web/app/api/client/[workspaceId]/responses/lib/response-error.ts index 7f3a8b56e44e..6e852d2c1e8f 100644 --- a/apps/web/app/api/client/[workspaceId]/responses/lib/response-error.ts +++ b/apps/web/app/api/client/[workspaceId]/responses/lib/response-error.ts @@ -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; }; diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.test.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.test.ts index 9881c35129c0..400e8e312a4f 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.test.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.test.ts @@ -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); @@ -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); diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.ts index e7607ff8a95a..f188f88a70c8 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.ts @@ -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"; @@ -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); } }; diff --git a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts index c36587c14dd8..80e8905de3c1 100644 --- a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts +++ b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts @@ -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( @@ -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( @@ -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( diff --git a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.ts b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.ts index 291541aab49c..50cde175f410 100644 --- a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.ts +++ b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.ts @@ -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"; @@ -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); } }; diff --git a/apps/web/lib/actionClass/service.ts b/apps/web/lib/actionClass/service.ts index 5a58873b01df..b7199a7c2237 100644 --- a/apps/web/lib/actionClass/service.ts +++ b/apps/web/lib/actionClass/service.ts @@ -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 = { @@ -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)[targetField] : ""} already exists` ); @@ -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)[targetField] : ""} already exists` ); diff --git a/apps/web/lib/feedback-source/service.test.ts b/apps/web/lib/feedback-source/service.test.ts index d427f298a77b..7ee7e966ea95 100644 --- a/apps/web/lib/feedback-source/service.test.ts +++ b/apps/web/lib/feedback-source/service.test.ts @@ -426,7 +426,9 @@ describe("createFeedbackSourceWithMappings", () => { new Prisma.PrismaClientKnownRequestError("Unique constraint", { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["workspaceId", "name"] }, + meta: { + driverAdapterError: { cause: { constraint: { fields: ["workspaceId", "name"] } } }, + }, }) ); @@ -444,7 +446,11 @@ describe("createFeedbackSourceWithMappings", () => { new Prisma.PrismaClientKnownRequestError("Unique constraint", { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["workspaceId", "feedbackSourceId", "surveyId", "elementId"] }, + meta: { + driverAdapterError: { + cause: { constraint: { fields: ["workspaceId", "feedbackSourceId", "surveyId", "elementId"] } }, + }, + }, }) ); @@ -462,7 +468,17 @@ describe("createFeedbackSourceWithMappings", () => { new Prisma.PrismaClientKnownRequestError("Unique constraint", { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["workspaceId", "feedbackSourceId", "sourceFieldId", "targetFieldId"] }, + // Real Prisma 7 + adapter-pg shape: no meta.target, and columns are the @map()-ed DB names + // (source_field_id / target_field_id) — verifies the helper's mapped-column handling. + meta: { + driverAdapterError: { + cause: { + constraint: { + fields: ["workspaceId", "feedbackSourceId", "source_field_id", "target_field_id"], + }, + }, + }, + }, }) ); @@ -651,7 +667,9 @@ describe("updateFeedbackSourceWithMappings", () => { new Prisma.PrismaClientKnownRequestError("Unique constraint", { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["workspaceId", "name"] }, + meta: { + driverAdapterError: { cause: { constraint: { fields: ["workspaceId", "name"] } } }, + }, }) ); @@ -665,7 +683,11 @@ describe("updateFeedbackSourceWithMappings", () => { new Prisma.PrismaClientKnownRequestError("Unique constraint", { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["workspaceId", "feedbackSourceId", "surveyId", "elementId"] }, + meta: { + driverAdapterError: { + cause: { constraint: { fields: ["workspaceId", "feedbackSourceId", "surveyId", "elementId"] } }, + }, + }, }) ); @@ -679,7 +701,17 @@ describe("updateFeedbackSourceWithMappings", () => { new Prisma.PrismaClientKnownRequestError("Unique constraint", { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["workspaceId", "feedbackSourceId", "sourceFieldId", "targetFieldId"] }, + // Real Prisma 7 + adapter-pg shape: no meta.target, and columns are the @map()-ed DB names + // (source_field_id / target_field_id) — verifies the helper's mapped-column handling. + meta: { + driverAdapterError: { + cause: { + constraint: { + fields: ["workspaceId", "feedbackSourceId", "source_field_id", "target_field_id"], + }, + }, + }, + }, }) ); diff --git a/apps/web/lib/feedback-source/service.ts b/apps/web/lib/feedback-source/service.ts index ffe4723f5f9b..43f65109e848 100644 --- a/apps/web/lib/feedback-source/service.ts +++ b/apps/web/lib/feedback-source/service.ts @@ -18,6 +18,7 @@ import { ZFeedbackSourceUpdateInput, } from "@formbricks/types/feedback-source"; import { ITEMS_PER_PAGE } from "../constants"; +import { getUniqueConstraintFields } from "../utils/prisma-constraint"; import { validateInputs } from "../utils/validate"; const selectFeedbackSourceWithMappings = { @@ -222,12 +223,15 @@ export const deleteFeedbackSource = async ( // -- Composite functions -- const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => { - const target = error.meta?.target; - const targetFields = Array.isArray(target) ? (target as string[]) : []; - if (targetFields.includes("elementId") || targetFields.includes("surveyId")) { + const fields = getUniqueConstraintFields(error); + // The driver adapter reports the underlying DB column names, so match the Prisma field name AND + // its @map()-ed column name for mapped fields (sourceFieldId/targetFieldId). Unmapped fields + // (elementId/surveyId) match by their identical column name. + const has = (...names: string[]): boolean => names.some((name) => fields.includes(name)); + if (has("elementId", "surveyId")) { return new InvalidInputError("FEEDBACK_SOURCE_FORMBRICKS_MAPPING_DUPLICATE"); } - if (targetFields.includes("sourceFieldId") || targetFields.includes("targetFieldId")) { + if (has("sourceFieldId", "source_field_id", "targetFieldId", "target_field_id")) { return new InvalidInputError("FEEDBACK_SOURCE_FIELD_MAPPING_DUPLICATE"); } return new InvalidInputError("FEEDBACK_SOURCE_NAME_DUPLICATE"); diff --git a/apps/web/lib/tagOnResponse/service.test.ts b/apps/web/lib/tagOnResponse/service.test.ts index bac0a925eb9d..192fae81cae2 100644 --- a/apps/web/lib/tagOnResponse/service.test.ts +++ b/apps/web/lib/tagOnResponse/service.test.ts @@ -140,7 +140,7 @@ describe("TagOnResponse Service", () => { { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["responseId", "tagId"] }, + meta: { driverAdapterError: { cause: { constraint: { fields: ["responseId", "tagId"] } } } }, } ); vi.mocked(prisma.tagsOnResponses.create).mockRejectedValue(prismaError); @@ -164,7 +164,7 @@ describe("TagOnResponse Service", () => { { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["responseId", "tagId"] }, + meta: { driverAdapterError: { cause: { constraint: { fields: ["responseId", "tagId"] } } } }, } ); @@ -195,7 +195,7 @@ describe("TagOnResponse Service", () => { { code: "P2002", clientVersion: "5.0.0", - meta: { target: ["someOtherField"] }, + meta: { driverAdapterError: { cause: { constraint: { fields: ["someOtherField"] } } } }, } ); vi.mocked(prisma.tagsOnResponses.create).mockRejectedValue(prismaError); diff --git a/apps/web/lib/tagOnResponse/service.ts b/apps/web/lib/tagOnResponse/service.ts index 5fc6e0893ceb..a40b021b11a0 100644 --- a/apps/web/lib/tagOnResponse/service.ts +++ b/apps/web/lib/tagOnResponse/service.ts @@ -5,6 +5,7 @@ import { Prisma } from "@formbricks/database/prisma"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; import { TTagsCount, TTagsOnResponses } from "@formbricks/types/tags"; +import { getUniqueConstraintFields, isUniqueConstraintError } from "../utils/prisma-constraint"; import { validateInputs } from "../utils/validate"; const selectTagsOnResponse = { @@ -30,17 +31,17 @@ export const addTagToRespone = async (responseId: string, tagId: string): Promis tagId, }; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - const target = error.meta?.target; - const isTagsOnResponsesUniqueViolation = - Array.isArray(target) && target.includes("responseId") && target.includes("tagId"); - - if (error.code === "P2002" && isTagsOnResponsesUniqueViolation) { + if (isUniqueConstraintError(error)) { + const fields = getUniqueConstraintFields(error); + if (fields.includes("responseId") && fields.includes("tagId")) { + // Idempotent: the tag is already on the response. return { responseId, tagId, }; } + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); } diff --git a/apps/web/lib/utils/prisma-constraint.integration.test.ts b/apps/web/lib/utils/prisma-constraint.integration.test.ts new file mode 100644 index 000000000000..1dae19618f7c --- /dev/null +++ b/apps/web/lib/utils/prisma-constraint.integration.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import { resetDb } from "@/integration/reset-db"; +import { getUniqueConstraintFields } from "@/lib/utils/prisma-constraint"; + +/** + * Locks the P2002 error shape against the REAL Prisma 7 + @prisma/adapter-pg stack (ENG-1801). + * + * The regression that motivated this shipped because the unit tests mocked a synthetic + * `{ meta: { target: [...] } }` shape that the driver adapter never actually produces. This drives + * genuine unique-constraint violations against real Postgres so a future Prisma/adapter upgrade that + * changes the meta shape fails HERE instead of silently returning 500s in production. + * + * `getUniqueConstraintFields` reads `error.meta` structurally, so it is unaffected by which generated + * client (the harness uses a separate test client) threw the error. + */ +beforeEach(async () => { + await resetDb(); +}); + +describe("getUniqueConstraintFields vs real Prisma 7 + adapter-pg (ENG-1801)", () => { + test("recovers the column of an unmapped unique field (User.email) — and meta.target is absent", async () => { + const email = "eng1801-integration@example.com"; + await prisma.user.create({ data: { name: "First", email } }); + + const error = await prisma.user.create({ data: { name: "Second", email } }).catch((e) => e); + + expect(error?.code).toBe("P2002"); + // The regression premise: the driver adapter no longer populates meta.target. + expect((error?.meta as { target?: unknown })?.target).toBeUndefined(); + expect(getUniqueConstraintFields(error)).toEqual(["email"]); + }); + + test("recovers the @map()-ed DB column name for a mapped unique field (PasswordResetToken.token_hash)", async () => { + const [userA, userB] = await Promise.all([ + prisma.user.create({ data: { name: "A", email: "eng1801-a@example.com" } }), + prisma.user.create({ data: { name: "B", email: "eng1801-b@example.com" } }), + ]); + const tokenHash = "eng1801-shared-token-hash"; + const expiresAt = new Date(Date.now() + 3_600_000); + await prisma.passwordResetToken.create({ data: { userId: userA.id, tokenHash, expiresAt } }); + + const error = await prisma.passwordResetToken + .create({ data: { userId: userB.id, tokenHash, expiresAt } }) + .catch((e) => e); + + expect(error?.code).toBe("P2002"); + // The adapter reports the DB column name (`token_hash`), not the Prisma field name (`tokenHash`). + expect(getUniqueConstraintFields(error)).toEqual(["token_hash"]); + }); +}); diff --git a/apps/web/lib/utils/prisma-constraint.test.ts b/apps/web/lib/utils/prisma-constraint.test.ts new file mode 100644 index 000000000000..4e84d0aa27af --- /dev/null +++ b/apps/web/lib/utils/prisma-constraint.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; +import { Prisma } from "@formbricks/database/prisma"; +import { getUniqueConstraintFields, isUniqueConstraintError } from "./prisma-constraint"; + +const knownError = (code: string, meta?: Record): Prisma.PrismaClientKnownRequestError => + new Prisma.PrismaClientKnownRequestError("boom", { code, clientVersion: "test", meta }); + +// The shape Prisma 7 + @prisma/adapter-pg actually produces (verified against a real P2002): no +// top-level `target`; the columns are nested under driverAdapterError.cause.constraint.fields. +const adapterP2002 = (fields: string[]): Prisma.PrismaClientKnownRequestError => + knownError("P2002", { + modelName: "User", + driverAdapterError: { + name: "DriverAdapterError", + cause: { + kind: "UniqueConstraintViolation", + originalCode: "23505", + originalMessage: `duplicate key value violates unique constraint "User_${fields.join("_")}_key"`, + constraint: { fields }, + }, + }, + }); + +// The legacy / library-engine shape (top-level target). Still supported as a fallback. +const legacyP2002 = (target: string[]): Prisma.PrismaClientKnownRequestError => + knownError("P2002", { target }); + +describe("isUniqueConstraintError", () => { + test("is true only for P2002", () => { + expect(isUniqueConstraintError(adapterP2002(["email"]))).toBe(true); + expect(isUniqueConstraintError(knownError("P2025"))).toBe(false); + expect(isUniqueConstraintError(new Error("plain"))).toBe(false); + expect(isUniqueConstraintError({ code: "P2002" })).toBe(false); + expect(isUniqueConstraintError(null)).toBe(false); + }); +}); + +describe("getUniqueConstraintFields", () => { + test("extracts columns from the Prisma 7 driver-adapter shape (no meta.target)", () => { + expect(adapterP2002(["email"]).meta?.target).toBeUndefined(); // guards the premise of the bug + expect(getUniqueConstraintFields(adapterP2002(["email"]))).toEqual(["email"]); + expect(getUniqueConstraintFields(adapterP2002(["responseId", "tagId"]))).toEqual(["responseId", "tagId"]); + }); + + test("extracts columns from the legacy top-level meta.target shape", () => { + expect(getUniqueConstraintFields(legacyP2002(["email"]))).toEqual(["email"]); + }); + + test("returns [] when neither shape is present (callers must still map to conflict, not 500)", () => { + expect(getUniqueConstraintFields(knownError("P2002"))).toEqual([]); + expect(getUniqueConstraintFields(knownError("P2002", { modelName: "User" }))).toEqual([]); + }); + + test("filters out non-string entries defensively", () => { + expect(getUniqueConstraintFields(legacyP2002(["email", null as unknown as string]))).toEqual(["email"]); + }); +}); diff --git a/apps/web/lib/utils/prisma-constraint.ts b/apps/web/lib/utils/prisma-constraint.ts new file mode 100644 index 000000000000..75f29587dd2b --- /dev/null +++ b/apps/web/lib/utils/prisma-constraint.ts @@ -0,0 +1,54 @@ +import { Prisma } from "@formbricks/database/prisma"; +import type { PrismaClientKnownRequestError } from "@formbricks/database/prisma"; + +/** Prisma unique-constraint violation code. */ +const UNIQUE_CONSTRAINT_VIOLATION = "P2002"; + +/** + * Type guard for a Prisma P2002 unique-constraint violation. + * + * Matches on the stable `error.code`, never on `error.meta` (which is not public API — see + * `getUniqueConstraintFields`). Uses the *named* `PrismaClientKnownRequestError` type for the + * predicate so the negative branch of the guard doesn't collapse to `never`. + */ +export const isUniqueConstraintError = (error: unknown): error is PrismaClientKnownRequestError => + error instanceof Prisma.PrismaClientKnownRequestError && error.code === UNIQUE_CONSTRAINT_VIOLATION; + +/** + * Returns the column names involved in a P2002 unique-constraint violation. + * + * Prisma's `error.meta` shape is explicitly NOT public API (prisma#28953) and differs by engine: + * - library / legacy query engine: `meta.target` is a `string[]` + * - Prisma 7 + `@prisma/adapter-pg` (this repo): `meta.target` is absent; the columns live at + * `meta.driverAdapterError.cause.constraint.fields` + * + * We read both, in that order — this is the ONLY place in the codebase that touches the unstable + * shape. Returns `[]` when neither is present (callers must still map P2002 to a conflict/domain + * error, never a 500). + * + * Security: only the structured column names are returned. Never surface `originalMessage`, the + * constraint name, or any other raw `driverAdapterError.cause` string to a response or log — the + * underlying Postgres unique-violation detail can contain the offending value (PII). + */ +export const getUniqueConstraintFields = (error: PrismaClientKnownRequestError): string[] => { + const meta = error.meta as + | { + target?: unknown; + driverAdapterError?: { cause?: { constraint?: { fields?: unknown } } }; + } + | undefined; + + // Legacy / library-engine shape. + const legacyTarget = meta?.target; + if (Array.isArray(legacyTarget)) { + return legacyTarget.filter((field): field is string => typeof field === "string"); + } + + // Prisma 7 driver-adapter shape. + const adapterFields = meta?.driverAdapterError?.cause?.constraint?.fields; + if (Array.isArray(adapterFields)) { + return adapterFields.filter((field): field is string => typeof field === "string"); + } + + return []; +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.integration.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.integration.test.ts new file mode 100644 index 000000000000..19096e297471 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.integration.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import { resetDb } from "@/integration/reset-db"; +import type { TUserInput } from "../../types/users"; +import { createUser } from "../users"; + +/** + * End-to-end (service + real Postgres) smoke test for the ENG-1801 regression: a duplicate-email + * create must return a 409 `conflict`, NOT a 500. This drives the exact bug path — real + * `prisma.user.create` → real P2002 from adapter-pg → `isUniqueConstraintError` → conflict — with no + * mocks, so it also confirms the `instanceof` guard works against a genuinely-thrown error. + */ +beforeEach(async () => { + await resetDb(); +}); + +describe("createUser duplicate handling vs real Postgres (ENG-1801)", () => { + test("returns 409 conflict (not 500) on a real duplicate-email unique violation", async () => { + const org = await prisma.organization.create({ data: { name: "ENG-1801 Org" } }); + const email = "eng1801-service@example.com"; + + const first = await createUser({ name: "First", email, role: "owner" } as TUserInput, org.id); + expect(first.ok).toBe(true); + + const second = await createUser({ name: "Second", email, role: "member" } as TUserInput, org.id); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error.type).toBe("conflict"); + expect(second.error.details).toEqual([ + { field: "email", issue: "A user with this email already exists" }, + ]); + } + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts index b150278dfb42..5739b73bb00c 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts @@ -109,11 +109,19 @@ describe("Users Lib", () => { }); test("returns conflict error if user with email already exists", async () => { + // Real Prisma 7 + adapter-pg P2002 shape: NO meta.target; columns nested under + // driverAdapterError.cause.constraint.fields. This is the shape that shipped the 500 bug. (prisma.user.create as any).mockRejectedValueOnce( - new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", { + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { code: PrismaErrorType.UniqueConstraintViolation, clientVersion: "1.0.0", - meta: { target: ["email"] }, + meta: { + modelName: "User", + driverAdapterError: { + name: "DriverAdapterError", + cause: { kind: "UniqueConstraintViolation", constraint: { fields: ["email"] } }, + }, + }, }) ); const result = await createUser( @@ -129,16 +137,15 @@ describe("Users Lib", () => { } }); - test("returns internal_server_error if unique constraint violation is not on email", async () => { + test("maps a driver-adapter P2002 with no recoverable fields to conflict, never 500 (ENG-1801)", async () => { + // Degrade-safe: even when neither meta.target nor constraint.fields is present, a P2002 on + // this create (email is the only unique key) must return 409, not fall through to a 500. (prisma.user.create as any).mockRejectedValueOnce( - new Prisma.PrismaClientKnownRequestError( - "Unique constraint failed on the fields: (`organizationId`,`email`)", - { - code: PrismaErrorType.UniqueConstraintViolation, - clientVersion: "1.0.0", - meta: { target: ["organizationId"] }, - } - ) + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "1.0.0", + meta: { modelName: "User", driverAdapterError: { cause: { kind: "UniqueConstraintViolation" } } }, + }) ); const result = await createUser( { name: "Duplicate", email: "test@example.com", role: "member" }, @@ -146,7 +153,7 @@ describe("Users Lib", () => { ); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error.type).toBe("internal_server_error"); + expect(result.error.type).toBe("conflict"); } }); }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts index f85085288b68..a8070e62cb73 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts @@ -1,8 +1,8 @@ import { prisma } from "@formbricks/database"; import { OrganizationRole, Prisma, TeamUserRole } from "@formbricks/database/prisma"; -import { PrismaErrorType } from "@formbricks/database/types/error"; import { TUser } from "@formbricks/database/zod/users"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { isUniqueConstraintError } from "@/lib/utils/prisma-constraint"; import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils"; import { TGetUsersFilter, @@ -143,18 +143,13 @@ export const createUser = async ( return ok(returnedUser); } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.UniqueConstraintViolation - ) { - const target = error.meta?.target as string[] | undefined; - - if (target?.includes("email")) { - return err({ - type: "conflict", - details: [{ field: "email", issue: "A user with this email already exists" }], - }); - } + if (isUniqueConstraintError(error)) { + // `email` is the only user-facing unique constraint on this create, so map any P2002 to a + // conflict rather than gating on the (adapter-dependent) field list — this can never 500. + return err({ + type: "conflict", + details: [{ field: "email", issue: "A user with this email already exists" }], + }); } return err({ diff --git a/apps/web/modules/survey/editor/lib/action-class.test.ts b/apps/web/modules/survey/editor/lib/action-class.test.ts index a7cc29dfdf8f..07780fded14f 100644 --- a/apps/web/modules/survey/editor/lib/action-class.test.ts +++ b/apps/web/modules/survey/editor/lib/action-class.test.ts @@ -105,7 +105,7 @@ describe("createActionClass", () => { code: PrismaErrorType.UniqueConstraintViolation, clientVersion: "test", }), - { meta: { target: ["name"] } } + { meta: { driverAdapterError: { cause: { constraint: { fields: ["name"] } } } } } ); vi.mocked(prisma.actionClass.create).mockRejectedValue(prismaError); diff --git a/apps/web/modules/survey/editor/lib/action-class.ts b/apps/web/modules/survey/editor/lib/action-class.ts index fb6f5ac173a4..5b075af702d6 100644 --- a/apps/web/modules/survey/editor/lib/action-class.ts +++ b/apps/web/modules/survey/editor/lib/action-class.ts @@ -1,8 +1,8 @@ import { prisma } from "@formbricks/database"; -import { ActionClass, Prisma } from "@formbricks/database/prisma"; -import { PrismaErrorType } from "@formbricks/database/types/error"; +import { ActionClass } from "@formbricks/database/prisma"; import { TActionClassInput } from "@formbricks/types/action-classes"; import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors"; +import { getUniqueConstraintFields, isUniqueConstraintError } from "@/lib/utils/prisma-constraint"; export const createActionClass = async ( workspaceId: string, @@ -27,11 +27,8 @@ export const createActionClass = async ( 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)[targetField] : ""} already exists` );