From c609348e5e77ef8081adec3edf63407f3fa11fb4 Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:10:00 +0000 Subject: [PATCH 1/3] feat: support app surveys in the v3 Surveys API (#8307) Co-authored-by: pandeymangg --- .../v3/action-classes/lib/operations.test.ts | 132 +++ .../api/v3/action-classes/lib/operations.ts | 13 + apps/web/app/api/v3/action-classes/route.ts | 25 + .../app/api/v3/action-classes/serializers.ts | 24 + .../lib/operations.test.ts | 164 +++ .../contact-attribute-keys/lib/operations.ts | 30 + .../api/v3/contact-attribute-keys/route.ts | 26 + .../v3/contact-attribute-keys/serializers.ts | 29 + .../app/api/v3/lib/cursor-pagination.test.ts | 50 + apps/web/app/api/v3/lib/cursor-pagination.ts | 57 + apps/web/app/api/v3/lib/list-resource.ts | 94 ++ apps/web/app/api/v3/lib/schemas.test.ts | 51 + apps/web/app/api/v3/lib/schemas.ts | 16 + .../app/api/v3/surveys/[surveyId]/route.ts | 3 + apps/web/app/api/v3/surveys/create.test.ts | 190 ++- apps/web/app/api/v3/surveys/create.ts | 95 +- .../app/api/v3/surveys/distribution.test.ts | 106 ++ apps/web/app/api/v3/surveys/distribution.ts | 51 + .../web/app/api/v3/surveys/generate/prompt.ts | 7 +- .../api/v3/surveys/generate/schemas.test.ts | 11 + .../app/api/v3/surveys/generate/schemas.ts | 2 +- .../api/v3/surveys/generate/service.test.ts | 50 + .../app/api/v3/surveys/generate/service.ts | 4 + .../app/api/v3/surveys/lib/operations.test.ts | 4 +- apps/web/app/api/v3/surveys/lib/operations.ts | 99 +- apps/web/app/api/v3/surveys/patch.test.ts | 200 +++- apps/web/app/api/v3/surveys/patch.ts | 116 +- apps/web/app/api/v3/surveys/prepare.ts | 16 +- apps/web/app/api/v3/surveys/schemas.test.ts | 138 +++ apps/web/app/api/v3/surveys/schemas.ts | 151 ++- .../app/api/v3/surveys/serializers.test.ts | 64 ++ apps/web/app/api/v3/surveys/serializers.ts | 6 + apps/web/app/api/v3/surveys/targeting.test.ts | 359 ++++++ apps/web/app/api/v3/surveys/targeting.ts | 280 +++++ apps/web/app/api/v3/surveys/triggers.ts | 54 + .../app/api/v3/surveys/write-permissions.ts | 41 +- .../web/lib/survey/service.scheduling.test.ts | 4 + apps/web/lib/survey/service.test.ts | 44 +- apps/web/lib/survey/service.ts | 72 +- apps/web/locales/de-DE.json | 3 +- apps/web/locales/en-US.json | 3 +- apps/web/locales/es-ES.json | 3 +- apps/web/locales/fr-FR.json | 3 +- apps/web/locales/hu-HU.json | 3 +- apps/web/locales/ja-JP.json | 3 +- apps/web/locales/nl-NL.json | 3 +- apps/web/locales/pt-BR.json | 3 +- apps/web/locales/pt-PT.json | 3 +- apps/web/locales/ro-RO.json | 3 +- apps/web/locales/ru-RU.json | 3 +- apps/web/locales/sv-SE.json | 3 +- apps/web/locales/tr-TR.json | 3 +- apps/web/locales/zh-Hans-CN.json | 3 +- apps/web/locales/zh-Hant-TW.json | 3 +- .../components/create-with-ai-form.tsx | 6 +- .../template-list/lib/ai-create-utils.test.ts | 2 +- .../template-list/lib/ai-create-utils.ts | 4 +- docs/api-v3-reference/openapi.yml | 1018 ++++++++++++++--- .../schemas/ActionClassResource.yml | 30 + .../schemas/ContactAttributeKeyResource.yml | 41 + .../schemas/CreateSurveyRequest.yml | 26 +- .../schemas/GenerateSurveyRequest.yml | 6 +- .../components/schemas/PatchSurveyRequest.yml | 16 +- .../src/components/schemas/Problem.yml | 2 + .../src/components/schemas/SegmentFilter.yml | 188 +++ .../components/schemas/SegmentFilterValue.yml | 27 + .../src/components/schemas/SegmentFilters.yml | 30 + .../components/schemas/SurveyDistribution.yml | 86 ++ .../src/components/schemas/SurveyListItem.yml | 30 +- .../src/components/schemas/SurveyResource.yml | 8 + .../components/schemas/SurveyTargeting.yml | 16 + .../src/components/schemas/SurveyTrigger.yml | 12 + docs/api-v3-reference/src/openapi.yml | 23 +- .../src/paths/api_v3_action-classes.yml | 90 ++ .../paths/api_v3_contact-attribute-keys.yml | 85 ++ .../src/paths/api_v3_surveys.yml | 171 ++- .../src/paths/api_v3_surveys_generate.yml | 42 +- .../src/paths/api_v3_surveys_validate.yml | 29 +- .../src/paths/api_v3_surveys_{surveyId}.yml | 125 +- 79 files changed, 4496 insertions(+), 540 deletions(-) create mode 100644 apps/web/app/api/v3/action-classes/lib/operations.test.ts create mode 100644 apps/web/app/api/v3/action-classes/lib/operations.ts create mode 100644 apps/web/app/api/v3/action-classes/route.ts create mode 100644 apps/web/app/api/v3/action-classes/serializers.ts create mode 100644 apps/web/app/api/v3/contact-attribute-keys/lib/operations.test.ts create mode 100644 apps/web/app/api/v3/contact-attribute-keys/lib/operations.ts create mode 100644 apps/web/app/api/v3/contact-attribute-keys/route.ts create mode 100644 apps/web/app/api/v3/contact-attribute-keys/serializers.ts create mode 100644 apps/web/app/api/v3/lib/cursor-pagination.test.ts create mode 100644 apps/web/app/api/v3/lib/cursor-pagination.ts create mode 100644 apps/web/app/api/v3/lib/list-resource.ts create mode 100644 apps/web/app/api/v3/lib/schemas.test.ts create mode 100644 apps/web/app/api/v3/lib/schemas.ts create mode 100644 apps/web/app/api/v3/surveys/distribution.test.ts create mode 100644 apps/web/app/api/v3/surveys/distribution.ts create mode 100644 apps/web/app/api/v3/surveys/targeting.test.ts create mode 100644 apps/web/app/api/v3/surveys/targeting.ts create mode 100644 apps/web/app/api/v3/surveys/triggers.ts create mode 100644 docs/api-v3-reference/src/components/schemas/ActionClassResource.yml create mode 100644 docs/api-v3-reference/src/components/schemas/ContactAttributeKeyResource.yml create mode 100644 docs/api-v3-reference/src/components/schemas/SegmentFilter.yml create mode 100644 docs/api-v3-reference/src/components/schemas/SegmentFilterValue.yml create mode 100644 docs/api-v3-reference/src/components/schemas/SegmentFilters.yml create mode 100644 docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml create mode 100644 docs/api-v3-reference/src/components/schemas/SurveyTargeting.yml create mode 100644 docs/api-v3-reference/src/components/schemas/SurveyTrigger.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_action-classes.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_contact-attribute-keys.yml diff --git a/apps/web/app/api/v3/action-classes/lib/operations.test.ts b/apps/web/app/api/v3/action-classes/lib/operations.test.ts new file mode 100644 index 000000000000..dab078268677 --- /dev/null +++ b/apps/web/app/api/v3/action-classes/lib/operations.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { DatabaseError } from "@formbricks/types/errors"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { getActionClasses } from "@/lib/actionClass/service"; +import { listV3ActionClasses } from "./operations"; + +vi.mock("server-only", () => ({})); + +vi.mock("@/app/api/v3/lib/auth", () => ({ + requireV3WorkspaceAccess: vi.fn(), +})); + +vi.mock("@/lib/actionClass/service", () => ({ + getActionClasses: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + withContext: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + })), + }, +})); + +const workspaceId = "clxx1234567890123456789012"; + +const actionClass = { + id: "claa1234567890123456789012", + name: "Checkout Complete", + description: null, + type: "code", + key: "checkout_complete", + noCodeConfig: null, + workspaceId, + createdAt: new Date("2026-04-21T10:00:00.000Z"), + updatedAt: new Date("2026-04-21T10:00:00.000Z"), +} as unknown as Awaited>[number]; + +const params = { + workspaceId, + limit: 50, + authentication: null, + requestId: "req_1", + instance: "/api/v3/action-classes", +}; + +describe("listV3ActionClasses", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("returns the public action-class shape for an authorized workspace", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getActionClasses).mockResolvedValue([actionClass]); + + const response = await listV3ActionClasses(params); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: [ + { + id: "claa1234567890123456789012", + name: "Checkout Complete", + description: null, + type: "code", + key: "checkout_complete", + }, + ], + meta: { limit: 50, nextCursor: null }, + }); + expect(getActionClasses).toHaveBeenCalledWith(workspaceId); + }); + + test("paginates with limit + cursor (nextCursor points to the next page)", async () => { + const second = { + ...actionClass, + id: "clbb1234567890123456789012", + key: "second", + } as typeof actionClass; + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getActionClasses).mockResolvedValue([actionClass, second]); + + const page1 = await listV3ActionClasses({ ...params, limit: 1 }); + const body1 = await page1.json(); + expect(body1.data).toHaveLength(1); + expect(body1.data[0].id).toBe("claa1234567890123456789012"); + expect(typeof body1.meta.nextCursor).toBe("string"); + + const page2 = await listV3ActionClasses({ ...params, limit: 1, cursor: body1.meta.nextCursor }); + const body2 = await page2.json(); + expect(body2.data).toHaveLength(1); + expect(body2.data[0].id).toBe("clbb1234567890123456789012"); + expect(body2.meta.nextCursor).toBeNull(); + }); + + test("returns 400 on a malformed cursor", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getActionClasses).mockResolvedValue([actionClass]); + + const response = await listV3ActionClasses({ ...params, cursor: "%%%%" }); + + expect(response.status).toBe(400); + }); + + test("returns the auth response and skips fetching when access is denied", async () => { + const denied = new Response("forbidden", { status: 403 }); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied); + + const response = await listV3ActionClasses(params); + + expect(response).toBe(denied); + expect(getActionClasses).not.toHaveBeenCalled(); + }); + + test("returns 500 when fetching action classes fails", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getActionClasses).mockRejectedValue(new DatabaseError("boom")); + + const response = await listV3ActionClasses(params); + + expect(response.status).toBe(500); + }); +}); diff --git a/apps/web/app/api/v3/action-classes/lib/operations.ts b/apps/web/app/api/v3/action-classes/lib/operations.ts new file mode 100644 index 000000000000..c4da3a538165 --- /dev/null +++ b/apps/web/app/api/v3/action-classes/lib/operations.ts @@ -0,0 +1,13 @@ +import "server-only"; +import { type TV3WorkspaceListParams, listV3WorkspaceResource } from "@/app/api/v3/lib/list-resource"; +import { getActionClasses } from "@/lib/actionClass/service"; +import { serializeV3ActionClass } from "../serializers"; + +export function listV3ActionClasses(params: TV3WorkspaceListParams): Promise { + return listV3WorkspaceResource({ + ...params, + resourceName: "action classes", + fetchAll: getActionClasses, + serialize: serializeV3ActionClass, + }); +} diff --git a/apps/web/app/api/v3/action-classes/route.ts b/apps/web/app/api/v3/action-classes/route.ts new file mode 100644 index 000000000000..f597889b2649 --- /dev/null +++ b/apps/web/app/api/v3/action-classes/route.ts @@ -0,0 +1,25 @@ +/** + * /api/v3/action-classes - list a workspace's action classes (read-only). + * Session cookie or x-api-key; scope by workspaceId only. Lets API/MCP clients discover the + * action-class ids referenced by app-survey triggers (`distribution.triggers[].actionClassId`). + */ +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { ZV3WorkspaceListQuery } from "@/app/api/v3/lib/schemas"; +import { listV3ActionClasses } from "./lib/operations"; + +export const GET = withV3ApiWrapper({ + auth: "both", + schemas: { + query: ZV3WorkspaceListQuery, + }, + handler: async ({ authentication, parsedInput, requestId, instance }) => { + return await listV3ActionClasses({ + workspaceId: parsedInput.query.workspaceId, + limit: parsedInput.query.limit, + cursor: parsedInput.query.cursor, + authentication, + requestId, + instance, + }); + }, +}); diff --git a/apps/web/app/api/v3/action-classes/serializers.ts b/apps/web/app/api/v3/action-classes/serializers.ts new file mode 100644 index 000000000000..c9ed67d80acd --- /dev/null +++ b/apps/web/app/api/v3/action-classes/serializers.ts @@ -0,0 +1,24 @@ +import type { TActionClass } from "@formbricks/types/action-classes"; + +/** + * Public v3 action-class shape. Exposes only what a client needs to reference an action class from an + * app-survey trigger (`actionClassId`) and disambiguate it; internal fields (`noCodeConfig`, + * `workspaceId`, timestamps) stay out of the contract. + */ +export type TV3ActionClassResource = { + id: string; + name: string; + description: string | null; + type: TActionClass["type"]; + key: string | null; +}; + +export function serializeV3ActionClass(actionClass: TActionClass): TV3ActionClassResource { + return { + id: actionClass.id, + name: actionClass.name, + description: actionClass.description, + type: actionClass.type, + key: actionClass.key, + }; +} diff --git a/apps/web/app/api/v3/contact-attribute-keys/lib/operations.test.ts b/apps/web/app/api/v3/contact-attribute-keys/lib/operations.test.ts new file mode 100644 index 000000000000..adddb32414c7 --- /dev/null +++ b/apps/web/app/api/v3/contact-attribute-keys/lib/operations.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { DatabaseError } from "@formbricks/types/errors"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; +import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { listV3ContactAttributeKeys } from "./operations"; + +vi.mock("server-only", () => ({})); + +vi.mock("@/app/api/v3/lib/auth", () => ({ + requireV3WorkspaceAccess: vi.fn(), +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByWorkspaceId: vi.fn(), +})); + +vi.mock("@/modules/ee/contacts/lib/contact-attribute-keys", () => ({ + getContactAttributeKeys: vi.fn(), +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsContactsEnabled: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + withContext: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + })), + }, +})); + +const workspaceId = "clxx1234567890123456789012"; + +const attributeKey = { + id: "clak1234567890123456789012", + createdAt: new Date("2026-04-21T10:00:00.000Z"), + updatedAt: new Date("2026-04-21T10:00:00.000Z"), + isUnique: false, + key: "plan", + name: "Plan", + description: "Subscription plan", + type: "custom", + dataType: "string", + workspaceId, +} as unknown as Awaited>[number]; + +const params = { + workspaceId, + limit: 50, + authentication: null, + requestId: "req_1", + instance: "/api/v3/contact-attribute-keys", +}; + +describe("listV3ContactAttributeKeys", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({ + id: "org_1", + } as Awaited>); + vi.mocked(getIsContactsEnabled).mockResolvedValue(true); + }); + + test("returns the public attribute-key shape for an authorized workspace", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKey]); + + const response = await listV3ContactAttributeKeys(params); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: [ + { + id: "clak1234567890123456789012", + key: "plan", + name: "Plan", + description: "Subscription plan", + type: "custom", + dataType: "string", + }, + ], + meta: { limit: 50, nextCursor: null }, + }); + expect(getContactAttributeKeys).toHaveBeenCalledWith(workspaceId); + }); + + test("paginates with limit + cursor (nextCursor points to the next page)", async () => { + const second = { + ...attributeKey, + id: "clbk1234567890123456789012", + key: "role", + } as typeof attributeKey; + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKey, second]); + + const page1 = await listV3ContactAttributeKeys({ ...params, limit: 1 }); + const body1 = await page1.json(); + expect(body1.data).toHaveLength(1); + expect(body1.data[0].id).toBe("clak1234567890123456789012"); + expect(typeof body1.meta.nextCursor).toBe("string"); + + const page2 = await listV3ContactAttributeKeys({ + ...params, + limit: 1, + cursor: body1.meta.nextCursor, + }); + const body2 = await page2.json(); + expect(body2.data).toHaveLength(1); + expect(body2.data[0].id).toBe("clbk1234567890123456789012"); + expect(body2.meta.nextCursor).toBeNull(); + }); + + test("returns 400 on a malformed cursor", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKey]); + + const response = await listV3ContactAttributeKeys({ ...params, cursor: "%%%%" }); + + expect(response.status).toBe(400); + }); + + test("returns the auth response and skips fetching when access is denied", async () => { + const denied = new Response("forbidden", { status: 403 }); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied); + + const response = await listV3ContactAttributeKeys(params); + + expect(response).toBe(denied); + expect(getContactAttributeKeys).not.toHaveBeenCalled(); + }); + + test("returns 403 when the contacts feature is not enabled for the organization", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getIsContactsEnabled).mockResolvedValue(false); + + const response = await listV3ContactAttributeKeys(params); + + expect(response.status).toBe(403); + expect(getContactAttributeKeys).not.toHaveBeenCalled(); + }); + + test("returns 500 when fetching attribute keys fails", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ + workspaceId, + } as Awaited>); + vi.mocked(getContactAttributeKeys).mockRejectedValue(new DatabaseError("boom")); + + const response = await listV3ContactAttributeKeys(params); + + expect(response.status).toBe(500); + }); +}); diff --git a/apps/web/app/api/v3/contact-attribute-keys/lib/operations.ts b/apps/web/app/api/v3/contact-attribute-keys/lib/operations.ts new file mode 100644 index 000000000000..d7f5e35a9ef7 --- /dev/null +++ b/apps/web/app/api/v3/contact-attribute-keys/lib/operations.ts @@ -0,0 +1,30 @@ +import "server-only"; +import { type TV3WorkspaceListParams, listV3WorkspaceResource } from "@/app/api/v3/lib/list-resource"; +import { problemForbidden } from "@/app/api/v3/lib/response"; +import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; +import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { serializeV3ContactAttributeKey } from "../serializers"; + +export function listV3ContactAttributeKeys(params: TV3WorkspaceListParams): Promise { + const { requestId, instance } = params; + return listV3WorkspaceResource({ + ...params, + resourceName: "contact attribute keys", + fetchAll: getContactAttributeKeys, + serialize: serializeV3ContactAttributeKey, + // Contact attribute keys belong to the enterprise Contacts feature — gate the list the same way + // the UI and management API do, so an unentitled organization can't enumerate them through v3. + assertEntitlement: async (workspaceId) => { + const organization = await getOrganizationByWorkspaceId(workspaceId); + if (organization && (await getIsContactsEnabled(organization.id))) { + return null; + } + return problemForbidden( + requestId, + "The contacts feature is not enabled for this organization.", + instance + ); + }, + }); +} diff --git a/apps/web/app/api/v3/contact-attribute-keys/route.ts b/apps/web/app/api/v3/contact-attribute-keys/route.ts new file mode 100644 index 000000000000..75fe326b937b --- /dev/null +++ b/apps/web/app/api/v3/contact-attribute-keys/route.ts @@ -0,0 +1,26 @@ +/** + * /api/v3/contact-attribute-keys - list a workspace's contact attribute keys (read-only). + * Session cookie or x-api-key; scope by workspaceId only. Lets API/MCP clients discover the + * `contactAttributeKey` values referenced by app-survey targeting filters + * (`targeting.filters[].root.contactAttributeKey`). + */ +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { ZV3WorkspaceListQuery } from "@/app/api/v3/lib/schemas"; +import { listV3ContactAttributeKeys } from "./lib/operations"; + +export const GET = withV3ApiWrapper({ + auth: "both", + schemas: { + query: ZV3WorkspaceListQuery, + }, + handler: async ({ authentication, parsedInput, requestId, instance }) => { + return await listV3ContactAttributeKeys({ + workspaceId: parsedInput.query.workspaceId, + limit: parsedInput.query.limit, + cursor: parsedInput.query.cursor, + authentication, + requestId, + instance, + }); + }, +}); diff --git a/apps/web/app/api/v3/contact-attribute-keys/serializers.ts b/apps/web/app/api/v3/contact-attribute-keys/serializers.ts new file mode 100644 index 000000000000..8c386274ba9e --- /dev/null +++ b/apps/web/app/api/v3/contact-attribute-keys/serializers.ts @@ -0,0 +1,29 @@ +import type { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; + +/** + * Public v3 contact-attribute-key shape. Exposes what an agent needs to author targeting filters: + * the `key` (used as `targeting.filters[].root.contactAttributeKey`) and the `dataType` (which hints + * the valid operators — number/date attributes support arithmetic/date operators). Internal fields + * (`isUnique`, `workspaceId`, timestamps) stay out of the contract. + */ +export type TV3ContactAttributeKeyResource = { + id: string; + key: string; + name: string | null; + description: string | null; + type: TContactAttributeKey["type"]; + dataType: TContactAttributeKey["dataType"]; +}; + +export function serializeV3ContactAttributeKey( + attributeKey: TContactAttributeKey +): TV3ContactAttributeKeyResource { + return { + id: attributeKey.id, + key: attributeKey.key, + name: attributeKey.name, + description: attributeKey.description, + type: attributeKey.type, + dataType: attributeKey.dataType, + }; +} diff --git a/apps/web/app/api/v3/lib/cursor-pagination.test.ts b/apps/web/app/api/v3/lib/cursor-pagination.test.ts new file mode 100644 index 000000000000..60254825fc21 --- /dev/null +++ b/apps/web/app/api/v3/lib/cursor-pagination.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { InvalidInputError } from "@formbricks/types/errors"; +import { paginateByIdCursor } from "./cursor-pagination"; + +const item = (id: string) => ({ id }); + +describe("paginateByIdCursor", () => { + test("returns an empty page with no cursor for an empty collection", () => { + expect(paginateByIdCursor([], { limit: 10 })).toEqual({ page: [], nextCursor: null }); + }); + + test("returns everything with nextCursor=null when the collection fits in one page", () => { + const result = paginateByIdCursor([item("a"), item("b")], { limit: 5 }); + expect(result.page.map((i) => i.id)).toEqual(["a", "b"]); + expect(result.nextCursor).toBeNull(); + }); + + test("returns nextCursor=null when the collection is exactly the page size", () => { + const result = paginateByIdCursor([item("a"), item("b")], { limit: 2 }); + expect(result.page).toHaveLength(2); + expect(result.nextCursor).toBeNull(); + }); + + test("orders by id and walks pages via the opaque cursor", () => { + const items = [item("c"), item("a"), item("b")]; // intentionally unordered + const page1 = paginateByIdCursor(items, { limit: 2 }); + expect(page1.page.map((i) => i.id)).toEqual(["a", "b"]); + expect(typeof page1.nextCursor).toBe("string"); + + const page2 = paginateByIdCursor(items, { limit: 2, cursor: page1.nextCursor! }); + expect(page2.page.map((i) => i.id)).toEqual(["c"]); + expect(page2.nextCursor).toBeNull(); + }); + + test("is robust to a stale cursor (the cursor row was deleted between pages)", () => { + const all = [item("a"), item("b"), item("c")]; + const page1 = paginateByIdCursor(all, { limit: 1 }); // -> ["a"], cursor for "a" + // "a" is deleted before the next request; paging by id > cursor still returns b, c + const page2 = paginateByIdCursor([item("b"), item("c")], { limit: 5, cursor: page1.nextCursor! }); + expect(page2.page.map((i) => i.id)).toEqual(["b", "c"]); + expect(page2.nextCursor).toBeNull(); + }); + + test("rejects a malformed (non-canonical) cursor", () => { + expect(() => paginateByIdCursor([item("a")], { limit: 5, cursor: "%%%%" })).toThrow(InvalidInputError); + expect(() => paginateByIdCursor([item("a")], { limit: 5, cursor: "not base64url!" })).toThrow( + InvalidInputError + ); + }); +}); diff --git a/apps/web/app/api/v3/lib/cursor-pagination.ts b/apps/web/app/api/v3/lib/cursor-pagination.ts new file mode 100644 index 000000000000..bbe9ea0abb03 --- /dev/null +++ b/apps/web/app/api/v3/lib/cursor-pagination.ts @@ -0,0 +1,57 @@ +import "server-only"; +import { InvalidInputError } from "@formbricks/types/errors"; + +/** Default/max page size for v3 reference-collection list endpoints (action classes, attribute keys). */ +export const V3_LIST_DEFAULT_LIMIT = 50; +export const V3_LIST_MAX_LIMIT = 100; + +const encodeIdCursor = (id: string): string => Buffer.from(id, "utf8").toString("base64url"); + +/** + * Decode an opaque cursor back to the id it was issued for. `Buffer.from(…, "base64url")` is lenient + * (it never throws — it silently drops invalid characters), so a malformed cursor would otherwise + * decode to garbage and return a wrong or empty page. Guard with a canonical round-trip check and + * reject anything we could not have issued, so the caller can surface a 400 (matching the survey-list + * cursor contract) instead of silently hiding data behind a 200. + */ +const decodeIdCursor = (cursor: string): string => { + try { + const decoded = Buffer.from(cursor, "base64url").toString("utf8"); + if (decoded.length === 0 || encodeIdCursor(decoded) !== cursor) { + throw new InvalidInputError("Invalid pagination cursor"); + } + return decoded; + } catch (error) { + // Any failure decoding or round-tripping a client-supplied cursor is a bad cursor, not a server + // fault — always surface it as InvalidInputError (→ 400) rather than letting an unexpected throw + // escape as a 500. + throw error instanceof InvalidInputError ? error : new InvalidInputError("Invalid pagination cursor"); + } +}; + +/** + * Stable, in-memory cursor pagination over a fully-fetched collection. + * + * Items are ordered by their `id` (cuid2 — unique and stable) and the opaque cursor encodes the last + * returned id, so paging stays correct across insertions/deletions between requests (it filters by + * `id > cursor` rather than by offset, so a deleted cursor row doesn't shift or duplicate a page). + * + * This is intended for **bounded reference collections** that the codebase already reads as a whole + * (request-deduped via React cache, not a persistent cache) — e.g. a workspace's action classes or + * contact-attribute keys. It bounds the response + * payload and yields the standard `{ data, meta: { nextCursor } }` contract without forking the + * canonical read query. Large or unbounded collections (surveys, responses, …) should instead + * paginate at the database with a `take`/`cursor` query. + */ +export function paginateByIdCursor( + items: readonly T[], + options: { limit: number; cursor?: string } +): { page: T[]; nextCursor: string | null } { + const ordered = [...items].sort((a, b) => a.id.localeCompare(b.id)); + const afterId = options.cursor ? decodeIdCursor(options.cursor) : null; + const remaining = afterId ? ordered.filter((item) => item.id.localeCompare(afterId) > 0) : ordered; + const page = remaining.slice(0, options.limit); + const lastItem = page.at(-1); + const nextCursor = remaining.length > options.limit && lastItem ? encodeIdCursor(lastItem.id) : null; + return { page, nextCursor }; +} diff --git a/apps/web/app/api/v3/lib/list-resource.ts b/apps/web/app/api/v3/lib/list-resource.ts new file mode 100644 index 000000000000..507dd4baf767 --- /dev/null +++ b/apps/web/app/api/v3/lib/list-resource.ts @@ -0,0 +1,94 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { paginateByIdCursor } from "@/app/api/v3/lib/cursor-pagination"; +import { problemBadRequest, problemInternalError, successListResponse } from "@/app/api/v3/lib/response"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; + +/** Shared params for the workspace-scoped v3 reference-list endpoints (action classes, attribute keys). */ +export type TV3WorkspaceListParams = { + workspaceId: string; + limit: number; + cursor?: string; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +type TListV3WorkspaceResourceConfig = TV3WorkspaceListParams & { + /** Human-readable resource name; used only in error logs. */ + resourceName: string; + /** Fetch the workspace's full list (request-deduped); it is paginated in memory below. */ + fetchAll: (workspaceId: string) => Promise; + /** Map a fetched row to its public response shape. */ + serialize: (row: TRow) => TOut; + /** Optional entitlement gate, run after auth: return a Response to short-circuit (e.g. 403), or null to allow. */ + assertEntitlement?: (workspaceId: string) => Promise; +}; + +/** + * Shared handler for the workspace-scoped v3 reference-list endpoints. Resolves workspace access, + * runs an optional entitlement gate, fetches the full list and paginates it in memory — these are + * bounded reference collections the codebase already reads as a whole (request-deduped via React + * cache), so they don't fork the canonical read with a DB-side `take`/`cursor` query (see + * paginateByIdCursor). A malformed cursor maps to 400; any other failure to 500. + */ +export async function listV3WorkspaceResource( + config: TListV3WorkspaceResourceConfig +): Promise { + const { + workspaceId, + limit, + cursor, + authentication, + requestId, + instance, + resourceName, + fetchAll, + serialize, + } = config; + const log = logger.withContext({ requestId, workspaceId }); + + try { + const authResult = await requireV3WorkspaceAccess( + authentication, + workspaceId, + "read", + requestId, + instance + ); + if (authResult instanceof Response) { + return authResult; + } + + if (config.assertEntitlement) { + const denied = await config.assertEntitlement(authResult.workspaceId); + if (denied) { + return denied; + } + } + + const rows = await fetchAll(authResult.workspaceId); + const { page, nextCursor } = paginateByIdCursor(rows, { limit, cursor }); + + return successListResponse( + page.map(serialize), + { limit, nextCursor }, + { requestId, cache: "private, no-store" } + ); + } catch (err) { + if (err instanceof InvalidInputError) { + log.warn({ statusCode: 400 }, "Invalid pagination cursor"); + return problemBadRequest(requestId, err.message, { + invalid_params: [{ name: "cursor", reason: err.message }], + instance, + }); + } + log.error( + { err }, + `${err instanceof DatabaseError ? "Database error" : "Unexpected error"} while listing ${resourceName}` + ); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} diff --git a/apps/web/app/api/v3/lib/schemas.test.ts b/apps/web/app/api/v3/lib/schemas.test.ts new file mode 100644 index 000000000000..b1b9a0fb40c3 --- /dev/null +++ b/apps/web/app/api/v3/lib/schemas.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import { ZV3WorkspaceListQuery } from "./schemas"; + +describe("ZV3WorkspaceListQuery", () => { + test("accepts a valid cuid2 workspaceId", () => { + const result = ZV3WorkspaceListQuery.safeParse({ workspaceId: "clseedworkspace000000000" }); + expect(result.success).toBe(true); + }); + + test("rejects a missing workspaceId", () => { + expect(ZV3WorkspaceListQuery.safeParse({}).success).toBe(false); + }); + + test("rejects a non-cuid2 workspaceId", () => { + expect(ZV3WorkspaceListQuery.safeParse({ workspaceId: "not a cuid" }).success).toBe(false); + }); + + test("rejects unknown query keys (strict)", () => { + const result = ZV3WorkspaceListQuery.safeParse({ + workspaceId: "clseedworkspace000000000", + sortBy: "name", + }); + expect(result.success).toBe(false); + }); + + test("defaults, coerces, and accepts limit + cursor", () => { + const dflt = ZV3WorkspaceListQuery.safeParse({ workspaceId: "clseedworkspace000000000" }); + expect(dflt.success).toBe(true); + if (dflt.success) expect(dflt.data.limit).toBe(50); + + const parsed = ZV3WorkspaceListQuery.safeParse({ + workspaceId: "clseedworkspace000000000", + limit: "25", + cursor: "abc", + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.limit).toBe(25); + expect(parsed.data.cursor).toBe("abc"); + } + }); + + test("rejects an out-of-range limit", () => { + expect( + ZV3WorkspaceListQuery.safeParse({ workspaceId: "clseedworkspace000000000", limit: 0 }).success + ).toBe(false); + expect( + ZV3WorkspaceListQuery.safeParse({ workspaceId: "clseedworkspace000000000", limit: 101 }).success + ).toBe(false); + }); +}); diff --git a/apps/web/app/api/v3/lib/schemas.ts b/apps/web/app/api/v3/lib/schemas.ts new file mode 100644 index 000000000000..23d9b993bfc9 --- /dev/null +++ b/apps/web/app/api/v3/lib/schemas.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { V3_LIST_DEFAULT_LIMIT, V3_LIST_MAX_LIMIT } from "@/app/api/v3/lib/cursor-pagination"; + +/** + * Shared query schema for workspace-scoped v3 reference-list endpoints (action classes, contact + * attribute keys): a required `workspaceId` plus the standard cursor-pagination `limit` + `cursor`. + */ +export const ZV3WorkspaceListQuery = z + .object({ + workspaceId: z.cuid2(), + limit: z.coerce.number().int().min(1).max(V3_LIST_MAX_LIMIT).default(V3_LIST_DEFAULT_LIMIT), + cursor: z.string().min(1).optional(), + }) + .strict(); + +export type TV3WorkspaceListQuery = z.infer; diff --git a/apps/web/app/api/v3/surveys/[surveyId]/route.ts b/apps/web/app/api/v3/surveys/[surveyId]/route.ts index 1ce912257708..a3b189f0349f 100644 --- a/apps/web/app/api/v3/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v3/surveys/[surveyId]/route.ts @@ -73,6 +73,9 @@ export const DELETE = withV3ApiWrapper({ targetType: "survey", schemas: { params: surveyParamsSchema, + // Reject unknown query params (e.g. a stray `?workspaceId=`) consistently with GET/PATCH on this + // resource — single-survey endpoints locate the survey by its globally-unique id, not workspaceId. + query: ZV3EmptyQuery, }, handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => { return await deleteV3Survey({ diff --git a/apps/web/app/api/v3/surveys/create.test.ts b/apps/web/app/api/v3/surveys/create.test.ts index 012de0279250..d9561458b8ea 100644 --- a/apps/web/app/api/v3/surveys/create.test.ts +++ b/apps/web/app/api/v3/surveys/create.test.ts @@ -1,12 +1,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import type { TSurvey } from "@formbricks/types/surveys/types"; +import { getActionClasses } from "@/lib/actionClass/service"; import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; -import { createSurvey } from "@/lib/survey/service"; +import { createSurvey, getSurvey } from "@/lib/survey/service"; import { getExternalUrlsPermission } from "@/modules/survey/lib/permission"; import { V3SurveyCreatePermissionError, createV3Survey } from "./create"; import { V3SurveyReferenceValidationError } from "./reference-validation"; import { ZV3CreateSurveyBody } from "./schemas"; +import { resolveV3ContactsEntitlement } from "./targeting"; vi.mock("server-only", () => ({})); @@ -20,6 +22,17 @@ vi.mock("@formbricks/database", () => ({ vi.mock("@/lib/survey/service", () => ({ createSurvey: vi.fn(), + getSurvey: vi.fn(), +})); + +vi.mock("@/lib/actionClass/service", () => ({ + getActionClasses: vi.fn(), +})); + +vi.mock("./targeting", () => ({ + resolveV3ContactsEntitlement: vi.fn(), + assertV3SurveyTargetingFilterReferences: vi.fn(), + V3_CONTACTS_NOT_ENABLED_MESSAGE: "Contact targeting is not enabled.", })); vi.mock("@/lib/organization/service", () => ({ @@ -128,6 +141,12 @@ describe("createV3Survey", () => { whitelabel: undefined, }); vi.mocked(getExternalUrlsPermission).mockResolvedValue(true); + vi.mocked(getActionClasses).mockResolvedValue([]); + vi.mocked(resolveV3ContactsEntitlement).mockResolvedValue({ + resolvedOrganizationId: "org_1", + isContactsEnabled: true, + }); + vi.mocked(getSurvey).mockResolvedValue(createdSurvey); }); test("maps the public v3 body to the internal create payload", async () => { @@ -180,7 +199,8 @@ describe("createV3Survey", () => { expect.objectContaining({ default: true, enabled: true }), expect.objectContaining({ default: false, enabled: true }), ], - }) + }), + [] ); expect(getOrganizationByWorkspaceId).not.toHaveBeenCalled(); expect(getExternalUrlsPermission).not.toHaveBeenCalled(); @@ -235,7 +255,8 @@ describe("createV3Survey", () => { languages: expect.arrayContaining([ expect.objectContaining({ language: expect.objectContaining({ code: "fr-FR" }), enabled: false }), ]), - }) + }), + [] ); }); @@ -404,4 +425,167 @@ describe("createV3Survey", () => { ).rejects.toThrow(V3SurveyReferenceValidationError); expect(createSurvey).not.toHaveBeenCalled(); }); + + describe("app surveys", () => { + const actionClass = { + id: "claa1234567890123456789012", + name: "Checkout Complete", + description: null, + type: "code" as const, + key: "checkout_complete", + noCodeConfig: null, + workspaceId, + createdAt: new Date("2026-04-21T10:00:00.000Z"), + updatedAt: new Date("2026-04-21T10:00:00.000Z"), + }; + + const appSurveyId = "clsvapp01234567890123456789"; + const appSegment = { id: "clsg1234567890123456789012", filters: [] }; + // `createSurvey` returns the survey BEFORE the private segment is connected (segment: null). + const createdAppSurvey = { + ...createdSurvey, + id: appSurveyId, + type: "app", + segment: null, + } as unknown as TSurvey; + // `getSurvey` re-reads it WITH the auto-created segment connected. + const appSurveyWithSegment = { + ...createdSurvey, + id: appSurveyId, + type: "app", + segment: appSegment, + } as unknown as TSurvey; + + beforeEach(() => { + vi.mocked(createSurvey).mockResolvedValue(createdAppSurvey); + vi.mocked(getSurvey).mockResolvedValue(appSurveyWithSegment); + }); + + const attributeFilters = [ + { + id: "clf01234567890123456789012", + connector: null, + resource: { + id: "clf11234567890123456789012", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }, + }, + ]; + + const buildAppBody = (overrides: Record = {}) => + ZV3CreateSurveyBody.parse({ + workspaceId, + name: "In-App Feedback", + type: "app", + defaultLanguage: "en-US", + blocks: [ + { + id: "clbk1234567890123456789012", + name: "Main", + elements: [ + { + id: "feedback", + type: "openText", + headline: { "en-US": "How is it going?" }, + required: false, + }, + ], + }, + ], + ...overrides, + }); + + test("forwards distribution scalars and resolved triggers to createSurvey", async () => { + vi.mocked(getActionClasses).mockResolvedValue([actionClass]); + + const body = buildAppBody({ + distribution: { + displayOption: "respondMultiple", + recontactDays: 7, + delay: 5, + triggers: [{ actionClassId: actionClass.id }], + }, + }); + + await createV3Survey(body, null, "req_app_1"); + + expect(createSurvey).toHaveBeenCalledWith( + workspaceId, + expect.objectContaining({ + type: "app", + displayOption: "respondMultiple", + recontactDays: 7, + delay: 5, + triggers: [{ actionClass }], + }), + [] + ); + // No targeting filters → empty private segment, no entitlement check. + expect(resolveV3ContactsEntitlement).not.toHaveBeenCalled(); + }); + + test("passes targeting filters into createSurvey and returns the re-read survey", async () => { + // Targeting is created atomically with the survey's private segment inside createSurvey (one + // transaction); the re-read then surfaces the connected segment + numeric display fields. + vi.mocked(getSurvey).mockResolvedValueOnce({ + ...appSurveyWithSegment, + segment: { ...appSegment, filters: attributeFilters }, + } as unknown as TSurvey); + const body = buildAppBody({ targeting: { filters: attributeFilters } }); + + const result = await createV3Survey(body, null, "req_app_2", "org_1"); + + expect(resolveV3ContactsEntitlement).toHaveBeenCalledWith(workspaceId, "org_1"); + expect(createSurvey).toHaveBeenCalledWith( + workspaceId, + expect.objectContaining({ type: "app" }), + attributeFilters + ); + expect(getSurvey).toHaveBeenCalledWith(appSurveyId); + expect(result.segment?.filters).toEqual(attributeFilters); + }); + + test("rolls back atomically when createSurvey fails (no partial survey)", async () => { + // Targeting is written inside createSurvey's transaction, so a failed segment/targeting write + // rolls the whole create back and surfaces an error instead of leaving a survey behind. + vi.mocked(createSurvey).mockRejectedValueOnce(new Error("segment write failed")); + const body = buildAppBody({ targeting: { filters: attributeFilters } }); + + await expect(createV3Survey(body, null, "req_app_targeting_fail", "org_1")).rejects.toThrow(); + expect(getSurvey).not.toHaveBeenCalled(); + }); + + test("rejects targeting when contacts are not enabled, before any write", async () => { + vi.mocked(resolveV3ContactsEntitlement).mockResolvedValue({ + resolvedOrganizationId: "org_1", + isContactsEnabled: false, + }); + const body = buildAppBody({ targeting: { filters: attributeFilters } }); + + await expect(createV3Survey(body, null, "req_app_3", "org_1")).rejects.toThrow( + V3SurveyCreatePermissionError + ); + expect(createSurvey).not.toHaveBeenCalled(); + }); + + test("rejects unknown trigger action class ids", async () => { + vi.mocked(getActionClasses).mockResolvedValue([]); + const body = buildAppBody({ distribution: { triggers: [{ actionClassId: actionClass.id }] } }); + + await expect(createV3Survey(body, null, "req_app_4")).rejects.toThrow(V3SurveyReferenceValidationError); + expect(createSurvey).not.toHaveBeenCalled(); + }); + + test("rejects duplicate trigger action class ids", async () => { + vi.mocked(getActionClasses).mockResolvedValue([actionClass]); + const body = buildAppBody({ + distribution: { triggers: [{ actionClassId: actionClass.id }, { actionClassId: actionClass.id }] }, + }); + + await expect(createV3Survey(body, null, "req_app_5")).rejects.toThrow(V3SurveyReferenceValidationError); + expect(createSurvey).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/app/api/v3/surveys/create.ts b/apps/web/app/api/v3/surveys/create.ts index daa3afa04756..2cf4844b8cc7 100644 --- a/apps/web/app/api/v3/surveys/create.ts +++ b/apps/web/app/api/v3/surveys/create.ts @@ -1,14 +1,22 @@ import "server-only"; -import type { TSurveyCreateInput } from "@formbricks/types/surveys/types"; +import type { TSurvey, TSurveyCreateInput } from "@formbricks/types/surveys/types"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { getActionClasses } from "@/lib/actionClass/service"; import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; -import { createSurvey } from "@/lib/survey/service"; +import { createSurvey, getSurvey } from "@/lib/survey/service"; import { getElementsFromBlocks } from "@/lib/survey/utils"; import { getExternalUrlsPermission } from "@/modules/survey/lib/permission"; +import { v3DistributionToScalars } from "./distribution"; import { type TV3SurveyLanguageRequest, ensureV3WorkspaceLanguages } from "./languages"; import { prepareV3SurveyCreate } from "./prepare"; import { V3SurveyReferenceValidationError } from "./reference-validation"; import type { TV3CreateSurveyBody } from "./schemas"; +import { + V3_CONTACTS_NOT_ENABLED_MESSAGE, + assertV3SurveyTargetingFilterReferences, + resolveV3ContactsEntitlement, +} from "./targeting"; +import { resolveV3SurveyTriggers } from "./triggers"; import { getV3SurveyMediaInvalidParams } from "./validation"; export type TV3SurveyCreateOptions = { @@ -71,6 +79,73 @@ async function assertV3SurveyCreatePermissions( } } +function hasV3SurveyTargetingFilters(input: TV3CreateSurveyBody): boolean { + return input.type === "app" && (input.targeting?.filters.length ?? 0) > 0; +} + +/** + * Contact targeting (segment filters) is an enterprise feature. Gate non-empty targeting before any + * write so an unentitled request fails with a 403 instead of creating a survey it can't fully configure. + */ +async function assertV3SurveyTargetingPermission( + input: TV3CreateSurveyBody, + organizationId?: string +): Promise { + if (!hasV3SurveyTargetingFilters(input)) { + return; + } + + const { resolvedOrganizationId, isContactsEnabled } = await resolveV3ContactsEntitlement( + input.workspaceId, + organizationId + ); + if (!resolvedOrganizationId) { + throw new V3SurveyCreatePermissionError( + `Unable to verify contact targeting permissions for workspaceId '${input.workspaceId}'.` + ); + } + if (!isContactsEnabled) { + throw new V3SurveyCreatePermissionError(V3_CONTACTS_NOT_ENABLED_MESSAGE); + } +} + +/** + * Map the app-only `distribution` block onto the create input. Display scalars carry concrete + * defaults (matching the DB defaults), and triggers are resolved to full action-class objects. + */ +async function buildV3AppSurveyCreateFields( + input: TV3CreateSurveyBody +): Promise> { + const distribution = input.distribution; + if (!distribution) { + return {}; + } + + const fields: Partial = { ...v3DistributionToScalars(distribution), triggers: [] }; + + if (distribution.triggers.length > 0) { + const actionClasses = await getActionClasses(input.workspaceId); + fields.triggers = resolveV3SurveyTriggers(distribution.triggers, actionClasses); + } + + return fields; +} + +/** + * Finalize a freshly created app survey by re-reading it. `createSurvey` returns the survey BEFORE + * its private segment is connected (the segment is linked in a separate, un-selected update) and + * without the Decimal→number transform for displayPercentage, so the re-read makes the response carry + * the connected segment — including any targeting filters, which `createSurvey` writes atomically at + * creation time — and the numeric display fields. + */ +async function finalizeV3AppSurveyCreate(survey: TSurvey, input: TV3CreateSurveyBody): Promise { + if (input.type !== "app") { + return survey; + } + + return (await getSurvey(survey.id)) ?? survey; +} + export async function executeV3SurveyCreate(params: { input: TV3CreateSurveyBody; authentication: TV3Authentication; @@ -84,6 +159,13 @@ export async function executeV3SurveyCreate(params: { throw new V3SurveyReferenceValidationError(mediaInvalidParams); } + // Resolve/validate app distribution (incl. trigger ids) and targeting attribute-key references + // before any DB write, so an invalid reference fails with a 422 instead of a partial write. + const appCreateFields = input.type === "app" ? await buildV3AppSurveyCreateFields(input) : {}; + if (input.type === "app") { + await assertV3SurveyTargetingFilterReferences(input.workspaceId, input.targeting?.filters ?? []); + } + const languages = await ensureV3WorkspaceLanguages(input.workspaceId, languageRequests, requestId); const surveyCreateInput: TSurveyCreateInput = { name: input.name, @@ -98,10 +180,16 @@ export async function executeV3SurveyCreate(params: { languages, questions: [], createdBy: getCreatedBy(authentication), + ...appCreateFields, ...surveyCreateInputOverrides, }; - return await createSurvey(input.workspaceId, surveyCreateInput); + // App targeting filters are created atomically with the survey's private segment inside + // `createSurvey` (a single transaction), so a failed targeting write can't leave a partial survey. + const privateSegmentFilters = input.type === "app" ? (input.targeting?.filters ?? []) : []; + const survey = await createSurvey(input.workspaceId, surveyCreateInput, privateSegmentFilters); + + return await finalizeV3AppSurveyCreate(survey, input); } export async function createV3Survey( @@ -117,6 +205,7 @@ export async function createV3Survey( } await assertV3SurveyCreatePermissions(input, organizationId, options); + await assertV3SurveyTargetingPermission(preparation.document, organizationId); return await executeV3SurveyCreate({ input: preparation.document, diff --git a/apps/web/app/api/v3/surveys/distribution.test.ts b/apps/web/app/api/v3/surveys/distribution.test.ts new file mode 100644 index 000000000000..ebdbfc8bfa9b --- /dev/null +++ b/apps/web/app/api/v3/surveys/distribution.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import { surveyToV3Distribution, surveyToV3Targeting, v3DistributionToScalars } from "./distribution"; +import type { TV3SurveyDistribution } from "./schemas"; + +const appSurvey = { + type: "app", + displayOption: "respondMultiple", + displayPercentage: 25, + displayLimit: 3, + recontactDays: 7, + autoClose: 30, + autoComplete: 100, + delay: 5, + triggers: [{ actionClass: { id: "claa1234567890123456789012" } }], + segment: { id: "seg_1", filters: [] }, +} as unknown as TSurvey; + +describe("surveyToV3Distribution", () => { + test("maps stored scalars and triggers to the public shape", () => { + expect(surveyToV3Distribution(appSurvey)).toEqual({ + displayOption: "respondMultiple", + displayPercentage: 25, + displayLimit: 3, + recontactDays: 7, + autoClose: 30, + autoComplete: 100, + delay: 5, + triggers: [{ actionClassId: "claa1234567890123456789012" }], + }); + }); + + test("clamps legacy out-of-bounds values to schema-valid ones so reconstruction never fails", () => { + const legacy = { + ...appSurvey, + autoComplete: 0, + displayPercentage: 150, + displayLimit: -1, + recontactDays: -3, + autoClose: -10, + delay: -5, + } as unknown as TSurvey; + + expect(surveyToV3Distribution(legacy)).toMatchObject({ + autoComplete: null, + displayPercentage: null, + displayLimit: null, + recontactDays: null, + autoClose: null, + delay: 0, + }); + }); +}); + +describe("v3DistributionToScalars", () => { + test("returns the scalar columns and drops triggers", () => { + const distribution: TV3SurveyDistribution = { + displayOption: "displaySome", + displayPercentage: 50, + displayLimit: null, + recontactDays: null, + autoClose: null, + autoComplete: null, + delay: 0, + triggers: [{ actionClassId: "claa1234567890123456789012" }], + }; + + const scalars = v3DistributionToScalars(distribution); + + expect(scalars).not.toHaveProperty("triggers"); + expect(scalars).toEqual({ + displayOption: "displaySome", + displayPercentage: 50, + displayLimit: null, + recontactDays: null, + autoClose: null, + autoComplete: null, + delay: 0, + }); + }); +}); + +describe("surveyToV3Targeting", () => { + test("returns empty filters when the survey has no segment", () => { + expect(surveyToV3Targeting({ segment: null } as unknown as TSurvey)).toEqual({ filters: [] }); + }); + + test("returns the segment filters verbatim", () => { + const filters = [ + { + id: "clf01234567890123456789012", + connector: null, + resource: { + id: "clf11234567890123456789012", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }, + }, + ]; + + expect(surveyToV3Targeting({ segment: { id: "seg_1", filters } } as unknown as TSurvey)).toEqual({ + filters, + }); + }); +}); diff --git a/apps/web/app/api/v3/surveys/distribution.ts b/apps/web/app/api/v3/surveys/distribution.ts new file mode 100644 index 000000000000..35db5a7db1d5 --- /dev/null +++ b/apps/web/app/api/v3/surveys/distribution.ts @@ -0,0 +1,51 @@ +import type { TSurvey } from "@formbricks/types/surveys/types"; +import type { TV3SurveyDistribution, TV3SurveyTargeting } from "./schemas"; + +const responseLimitOrNull = (value: number | null): number | null => + typeof value === "number" && value >= 1 ? value : null; +const nonNegativeOrNull = (value: number | null): number | null => + typeof value === "number" && value >= 0 ? value : null; +const percentageOrNull = (value: number | null): number | null => + typeof value === "number" && value >= 0.01 && value <= 100 ? value : null; + +/** Internal survey triggers → the public `{ actionClassId }[]` shape. */ +function surveyTriggersToV3(triggers: TSurvey["triggers"]): TV3SurveyDistribution["triggers"] { + return triggers.map((trigger) => ({ actionClassId: trigger.actionClass.id })); +} + +/** + * Build the public `distribution` block from a stored survey — the single mapper used by both the GET + * serializer and the PATCH document reconstruction (so the two can't drift). Defensively clamps any + * stored value that predates or violates the current public constraints to a schema-valid one, so + * reconstructing an existing survey for PATCH never fails and GET never emits out-of-contract values. + * For normally-authored surveys these clamps are no-ops. + */ +export function surveyToV3Distribution(survey: TSurvey): TV3SurveyDistribution { + return { + displayOption: survey.displayOption, + displayPercentage: percentageOrNull(survey.displayPercentage), + displayLimit: nonNegativeOrNull(survey.displayLimit), + recontactDays: nonNegativeOrNull(survey.recontactDays), + autoClose: nonNegativeOrNull(survey.autoClose), + autoComplete: responseLimitOrNull(survey.autoComplete), + delay: nonNegativeOrNull(survey.delay) ?? 0, + triggers: surveyTriggersToV3(survey.triggers), + }; +} + +/** Build the public `targeting` block from a stored survey's segment (empty filters = "show everyone"). */ +export function surveyToV3Targeting(survey: TSurvey): TV3SurveyTargeting { + return { filters: survey.segment?.filters ?? [] }; +} + +/** + * Public `distribution` scalars → the internal survey scalar columns (used by the create and patch + * write paths). Triggers are intentionally excluded — create resolves them to full action-class + * objects and patch turns them into a relation diff, so each caller handles triggers itself. + */ +export function v3DistributionToScalars( + distribution: TV3SurveyDistribution +): Omit { + const { triggers: _triggers, ...scalars } = distribution; + return scalars; +} diff --git a/apps/web/app/api/v3/surveys/generate/prompt.ts b/apps/web/app/api/v3/surveys/generate/prompt.ts index fc89b0f6d550..4afe280b1df9 100644 --- a/apps/web/app/api/v3/surveys/generate/prompt.ts +++ b/apps/web/app/api/v3/surveys/generate/prompt.ts @@ -38,7 +38,12 @@ export function buildV3SurveyGenerationSystemPrompt( "embedded external forms, or any other question type outside the allowed list.", "Do not include branching, variables, hidden fields, URLs, files, scripts, markdown, HTML, or tracking instructions.", `The final product will be created as a ${surveyType} survey draft.`, - ].join("\n"); + surveyType === "app" + ? "This is an in-app survey shown inside the user's web or mobile app, so favor short questions that are easy to answer inside a small embedded widget." + : "", + ] + .filter(Boolean) + .join("\n"); } export function buildV3SurveyGenerationPrompt( diff --git a/apps/web/app/api/v3/surveys/generate/schemas.test.ts b/apps/web/app/api/v3/surveys/generate/schemas.test.ts index c2359eff54f2..69791cccaf83 100644 --- a/apps/web/app/api/v3/surveys/generate/schemas.test.ts +++ b/apps/web/app/api/v3/surveys/generate/schemas.test.ts @@ -341,6 +341,17 @@ describe("ZV3SurveyGenerateBody", () => { expect(result.success).toBe(true); }); + test("accepts both link and app survey types and defaults to link", () => { + const base = { + workspaceId: "clxx1234567890123456789012", + prompt: "Measure onboarding completion for new users.", + }; + + expect(ZV3SurveyGenerateBody.parse(base).type).toBe("link"); + expect(ZV3SurveyGenerateBody.parse({ ...base, type: "app" }).type).toBe("app"); + expect(ZV3SurveyGenerateBody.safeParse({ ...base, type: "website" }).success).toBe(false); + }); + test("normalizes requested survey language aliases into supported app locales", () => { const cases = [ { input: "es_ES", expected: "es-ES" }, diff --git a/apps/web/app/api/v3/surveys/generate/schemas.ts b/apps/web/app/api/v3/surveys/generate/schemas.ts index 499752d3b051..869d2d2ac962 100644 --- a/apps/web/app/api/v3/surveys/generate/schemas.ts +++ b/apps/web/app/api/v3/surveys/generate/schemas.ts @@ -61,7 +61,7 @@ export const ZV3SurveyGenerateBody = z V3_SURVEY_GENERATE_PROMPT_MAX_LENGTH, `Prompt must be ${V3_SURVEY_GENERATE_PROMPT_MAX_LENGTH} characters or less` ), - type: z.literal("link").prefault("link"), + type: z.enum(["link", "app"]).prefault("link"), language: ZV3SurveyGenerateLanguage.optional(), }) .strict(); diff --git a/apps/web/app/api/v3/surveys/generate/service.test.ts b/apps/web/app/api/v3/surveys/generate/service.test.ts index 7f5e705d887d..a2a7bf1e4c54 100644 --- a/apps/web/app/api/v3/surveys/generate/service.test.ts +++ b/apps/web/app/api/v3/surveys/generate/service.test.ts @@ -195,6 +195,56 @@ describe("generateV3SurveyCreatePayloadFromPrompt", () => { }); }); + test("seeds a default app distribution when generating an app survey", async () => { + vi.mocked(generateOrganizationAIObject).mockResolvedValueOnce({ + object: { + language: "en-US", + name: "In-App Onboarding Feedback", + description: null, + welcomeCard: { enabled: false, headline: null, subheader: null, buttonLabel: null }, + blocks: [ + { + name: "Onboarding", + questions: [ + { + type: "openText", + headline: "How was your setup experience?", + subheader: null, + required: false, + placeholder: null, + longAnswer: true, + choices: null, + lowerLabel: null, + upperLabel: null, + scale: null, + range: null, + }, + ], + }, + ], + ending: { headline: "Thanks for the feedback", subheader: null }, + }, + } as Awaited>); + + const result = await generateV3SurveyCreatePayloadFromPrompt({ + organizationId: "org_1", + input: { + workspaceId, + type: "app", + prompt: "Create an in-app onboarding feedback survey for brand new users.", + }, + }); + + const generationOptions = vi.mocked(generateOrganizationAIObject).mock.calls[0][0]; + expect(generationOptions.system).toContain("app survey draft"); + + expect(result.payload.type).toBe("app"); + expect(result.payload.distribution).toMatchObject({ displayOption: "displayOnce", triggers: [] }); + expect(result.payload).not.toHaveProperty("targeting"); + expect(result.validation.valid).toBe(true); + expect(prepareV3SurveyCreateInput(result.payload).ok).toBe(true); + }); + test("maps generated blocks to separate v3 create blocks", async () => { vi.mocked(generateOrganizationAIObject).mockResolvedValueOnce({ object: { diff --git a/apps/web/app/api/v3/surveys/generate/service.ts b/apps/web/app/api/v3/surveys/generate/service.ts index 9e61813ea6d7..5a0952415fa0 100644 --- a/apps/web/app/api/v3/surveys/generate/service.ts +++ b/apps/web/app/api/v3/surveys/generate/service.ts @@ -353,6 +353,10 @@ function buildCreatePayload(input: TV3SurveyGenerateBody, generatedSurvey: TGene ], hiddenFields: { enabled: false }, variables: [], + // AI generates content only. For app surveys, seed a valid default distribution (display once, + // no triggers, no targeting) the user finishes wiring in the editor. Omitted scalars fall back to + // the schema/DB defaults; the auto-created empty segment means "show to everyone". + ...(input.type === "app" ? { distribution: { displayOption: "displayOnce", triggers: [] } } : {}), }; } diff --git a/apps/web/app/api/v3/surveys/lib/operations.test.ts b/apps/web/app/api/v3/surveys/lib/operations.test.ts index 30b1e3f2ac3c..3db2ce0d2412 100644 --- a/apps/web/app/api/v3/surveys/lib/operations.test.ts +++ b/apps/web/app/api/v3/surveys/lib/operations.test.ts @@ -393,7 +393,7 @@ describe("createV3SurveyResponse", () => { instance, }) ).status - ).toBe(400); + ).toBe(422); vi.mocked(createV3Survey).mockRejectedValueOnce(new V3SurveyUnsupportedShapeError("Unsupported")); expect( @@ -681,7 +681,7 @@ describe("patchV3SurveyResponse", () => { instance, }) ).status - ).toBe(400); + ).toBe(422); vi.mocked(patchV3Survey).mockRejectedValueOnce(new V3SurveyUnsupportedShapeError("Unsupported")); expect( diff --git a/apps/web/app/api/v3/surveys/lib/operations.ts b/apps/web/app/api/v3/surveys/lib/operations.ts index 53681fbd699d..ce3501b5bb1e 100644 --- a/apps/web/app/api/v3/surveys/lib/operations.ts +++ b/apps/web/app/api/v3/surveys/lib/operations.ts @@ -1,7 +1,7 @@ import "server-only"; import { z } from "zod"; import { logger } from "@formbricks/logger"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; import { createdResponse, @@ -9,6 +9,7 @@ import { problemBadRequest, problemForbidden, problemInternalError, + problemUnprocessableContent, successListResponse, successResponse, } from "@/app/api/v3/lib/response"; @@ -202,6 +203,59 @@ export async function listV3Surveys({ } } +/** + * Map an error thrown during survey creation to its problem+json Response. Extracted from + * createV3SurveyResponse to keep that handler's cognitive complexity within bounds. + */ +function mapV3SurveyCreateError( + err: unknown, + { + log, + requestId, + instance, + }: { log: ReturnType; requestId: string; instance: string } +): Response { + if (err instanceof V3SurveyReferenceValidationError) { + // Well-formed JSON that fails semantic/reference validation (dangling refs, duplicate ids, + // undeclared locales, invalid media, unknown action-class ids) → 422, not 400 (which is reserved + // for malformed/unknown-field requests rejected at the schema layer). + log.warn({ statusCode: 422, invalidParams: err.invalidParams }, "Survey document validation failed"); + return problemUnprocessableContent(requestId, "Survey document failed validation", { + invalid_params: err.invalidParams, + instance, + }); + } + if (err instanceof V3SurveyUnsupportedShapeError) { + log.warn({ statusCode: 400, errorCode: err.name }, "Unsupported survey shape"); + return problemBadRequest(requestId, err.message, { + invalid_params: [{ name: "body", reason: err.message }], + instance, + }); + } + if (err instanceof V3SurveyCreatePermissionError) { + log.warn({ statusCode: 403, errorCode: err.name }, "Survey create permission check failed"); + return problemForbidden(requestId, err.message, instance); + } + if (err instanceof ResourceNotFoundError) { + log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found"); + return problemForbidden(requestId, "You are not authorized to access this resource", instance); + } + if (err instanceof InvalidInputError) { + log.warn({ statusCode: 400, errorCode: err.name }, "Invalid survey input"); + return problemBadRequest(requestId, err.message, { + invalid_params: [{ name: "body", reason: err.message }], + instance, + }); + } + if (err instanceof DatabaseError) { + log.error({ error: err, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + + log.error({ error: err, statusCode: 500 }, "V3 survey create unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); +} + export async function createV3SurveyResponse({ body, authentication, @@ -271,35 +325,7 @@ export async function createV3SurveyResponse({ location: `/api/v3/surveys/${survey.id}`, }); } catch (err) { - if (err instanceof V3SurveyReferenceValidationError) { - log.warn({ statusCode: 400, invalidParams: err.invalidParams }, "Survey document validation failed"); - return problemBadRequest(requestId, "Invalid survey document", { - invalid_params: err.invalidParams, - instance, - }); - } - if (err instanceof V3SurveyUnsupportedShapeError) { - log.warn({ statusCode: 400, errorCode: err.name }, "Unsupported survey shape"); - return problemBadRequest(requestId, err.message, { - invalid_params: [{ name: "body", reason: err.message }], - instance, - }); - } - if (err instanceof V3SurveyCreatePermissionError) { - log.warn({ statusCode: 403, errorCode: err.name }, "Survey create permission check failed"); - return problemForbidden(requestId, err.message, instance); - } - if (err instanceof ResourceNotFoundError) { - log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found"); - return problemForbidden(requestId, "You are not authorized to access this resource", instance); - } - if (err instanceof DatabaseError) { - log.error({ error: err, statusCode: 500 }, "Database error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } - - log.error({ error: err, statusCode: 500 }, "V3 survey create unexpected error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); + return mapV3SurveyCreateError(err, { log, requestId, instance }); } } @@ -490,11 +516,12 @@ export async function patchV3SurveyResponse({ }); } catch (error) { if (error instanceof V3SurveyReferenceValidationError) { + // Semantic/reference validation failure on a well-formed document → 422 (see create handler). log.warn( - { statusCode: 400, workspaceId, invalidParamCount: error.invalidParams.length }, + { statusCode: 422, workspaceId, invalidParamCount: error.invalidParams.length }, "Survey document validation failed" ); - return problemBadRequest(requestId, "Invalid survey document", { + return problemUnprocessableContent(requestId, "Survey document failed validation", { invalid_params: error.invalidParams, instance, }); @@ -526,6 +553,14 @@ export async function patchV3SurveyResponse({ return problemForbidden(requestId, "You are not authorized to access this resource", instance); } + if (error instanceof InvalidInputError) { + log.warn({ errorCode: error.name, workspaceId, statusCode: 400 }, "Invalid survey input"); + return problemBadRequest(requestId, error.message, { + invalid_params: [{ name: "body", reason: error.message }], + instance, + }); + } + if (error instanceof DatabaseError) { log.error({ error, workspaceId, statusCode: 500 }, "Database error"); return problemInternalError(requestId, "An unexpected error occurred.", instance); diff --git a/apps/web/app/api/v3/surveys/patch.test.ts b/apps/web/app/api/v3/surveys/patch.test.ts index 6244efb205e8..a25849b4164f 100644 --- a/apps/web/app/api/v3/surveys/patch.test.ts +++ b/apps/web/app/api/v3/surveys/patch.test.ts @@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import type { TSurvey } from "@formbricks/types/surveys/types"; +import { getActionClasses } from "@/lib/actionClass/service"; import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; import { getExternalUrlsPermission } from "@/modules/survey/lib/permission"; import { @@ -13,12 +14,17 @@ import { import { executeV3SurveyPatch, patchV3Survey } from "./patch"; import { V3SurveyReferenceValidationError } from "./reference-validation"; import { ZV3CreateSurveyBody } from "./schemas"; +import { + areV3SurveyTargetingFiltersEqual, + resolveV3ContactsEntitlement, + setV3SurveySegmentFilters, +} from "./targeting"; import { V3SurveyWritePermissionError } from "./write-permissions"; vi.mock("server-only", () => ({})); -vi.mock("@formbricks/database", () => ({ - prisma: { +vi.mock("@formbricks/database", () => { + const prisma = { language: { upsert: vi.fn(), }, @@ -26,8 +32,14 @@ vi.mock("@formbricks/database", () => ({ findUnique: vi.fn(), update: vi.fn(), }, - }, -})); + segment: { + update: vi.fn(), + }, + // Interactive transaction: run the callback with the mocked client as the tx client. + $transaction: vi.fn((callback: (tx: typeof prisma) => Promise) => callback(prisma)), + }; + return { prisma }; +}); vi.mock("@/lib/survey/service", () => ({ selectSurvey: { @@ -35,6 +47,18 @@ vi.mock("@/lib/survey/service", () => ({ }, })); +vi.mock("@/lib/actionClass/service", () => ({ + getActionClasses: vi.fn(), +})); + +vi.mock("./targeting", () => ({ + setV3SurveySegmentFilters: vi.fn(), + areV3SurveyTargetingFiltersEqual: vi.fn(), + assertV3SurveyTargetingFilterReferences: vi.fn(), + resolveV3ContactsEntitlement: vi.fn(), + V3_CONTACTS_NOT_ENABLED_MESSAGE: "Contact targeting is not enabled.", +})); + vi.mock("@/lib/organization/service", () => ({ getOrganizationByWorkspaceId: vi.fn(), })); @@ -202,6 +226,8 @@ describe("patchV3Survey", () => { publishOn: data.publishOn ?? currentSurvey.publishOn, }) as unknown as TSurveyUpdateReturn; }); + vi.mocked(prisma.$transaction).mockImplementation(((callback: (tx: typeof prisma) => Promise) => + callback(prisma)) as typeof prisma.$transaction); vi.mocked(normalizeSurveyScheduling).mockImplementation(({ closeOn, publishOn }) => ({ closeOn, publishOn, @@ -226,6 +252,12 @@ describe("patchV3Survey", () => { whitelabel: undefined, }); vi.mocked(getExternalUrlsPermission).mockResolvedValue(true); + vi.mocked(getActionClasses).mockResolvedValue([]); + vi.mocked(resolveV3ContactsEntitlement).mockResolvedValue({ + resolvedOrganizationId: "org_1", + isContactsEnabled: true, + }); + vi.mocked(areV3SurveyTargetingFiltersEqual).mockReturnValue(true); }); test("patches a name-only payload through v3 persistence", async () => { @@ -606,4 +638,164 @@ describe("patchV3Survey", () => { expect(getExternalUrlsPermission).not.toHaveBeenCalled(); expect(prisma.survey.update).not.toHaveBeenCalled(); }); + + describe("app surveys", () => { + const actionClass = { + id: "claa1234567890123456789012", + name: "Checkout Complete", + description: null, + type: "code" as const, + key: "checkout_complete", + noCodeConfig: null, + workspaceId, + createdAt: new Date("2026-04-21T10:00:00.000Z"), + updatedAt: new Date("2026-04-21T10:00:00.000Z"), + }; + + const appCurrentSurvey = { + ...currentSurvey, + type: "app", + recontactDays: 7, + triggers: [], + segment: { id: "clsg1234567890123456789012", filters: [] }, + } as unknown as TSurvey; + + const attributeFilters = [ + { + id: "clf01234567890123456789012", + connector: null, + resource: { + id: "clf11234567890123456789012", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }, + }, + ]; + + test("writes distribution scalars and the trigger diff", async () => { + vi.mocked(getActionClasses).mockResolvedValue([actionClass]); + + await patchV3Survey( + appCurrentSurvey, + { + distribution: { + displayOption: "displayMultiple", + recontactDays: 14, + triggers: [{ actionClassId: actionClass.id }], + }, + }, + "req_app_patch_1", + "org_1" + ); + + expect(prisma.survey.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + displayOption: "displayMultiple", + recontactDays: 14, + triggers: { create: [{ actionClassId: actionClass.id }] }, + }), + }) + ); + }); + + test("preserves omitted app fields under replacement merge", async () => { + await patchV3Survey(appCurrentSurvey, { name: "Renamed" }, "req_app_patch_2", "org_1"); + + expect(prisma.survey.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "Renamed", + recontactDays: 7, + displayOption: "displayOnce", + }), + }) + ); + }); + + test("updates segment filters when targeting changes and contacts are enabled", async () => { + vi.mocked(areV3SurveyTargetingFiltersEqual).mockReturnValue(false); + // Run the interactive transaction with a DISTINCT tx client so the assertions prove both writes + // go through it (not the global prisma) — i.e. that the update really is transactional. + const tx = { + survey: { update: vi.fn(vi.mocked(prisma.survey.update).getMockImplementation()) }, + segment: { update: vi.fn() }, + }; + vi.mocked(prisma.$transaction).mockImplementationOnce((( + callback: (client: typeof tx) => Promise + ) => callback(tx)) as unknown as typeof prisma.$transaction); + + await patchV3Survey( + appCurrentSurvey, + { targeting: { filters: attributeFilters } }, + "req_app_patch_3", + "org_1" + ); + + expect(resolveV3ContactsEntitlement).toHaveBeenCalledWith(workspaceId, "org_1"); + // Segment-filter write and survey update must use the SAME tx client (true atomic behavior). + expect(setV3SurveySegmentFilters).toHaveBeenCalledWith( + "clsg1234567890123456789012", + attributeFilters, + tx + ); + expect(tx.survey.update).toHaveBeenCalled(); + expect(prisma.survey.update).not.toHaveBeenCalled(); + }); + + test("rejects a targeting change when the app survey has no segment", async () => { + // App surveys always get an auto-created segment; if one is missing, a targeting change must + // fail loudly rather than return 200 while silently dropping the filters. + vi.mocked(areV3SurveyTargetingFiltersEqual).mockReturnValue(false); + const segmentlessSurvey = { ...appCurrentSurvey, segment: null } as unknown as TSurvey; + + await expect( + patchV3Survey( + segmentlessSurvey, + { targeting: { filters: attributeFilters } }, + "req_app_no_segment", + "org_1" + ) + ).rejects.toThrow(V3SurveyReferenceValidationError); + + expect(setV3SurveySegmentFilters).not.toHaveBeenCalled(); + expect(prisma.survey.update).not.toHaveBeenCalled(); + }); + + test("rejects targeting changes when contacts are not enabled", async () => { + vi.mocked(areV3SurveyTargetingFiltersEqual).mockReturnValue(false); + vi.mocked(resolveV3ContactsEntitlement).mockResolvedValue({ + resolvedOrganizationId: "org_1", + isContactsEnabled: false, + }); + + await expect( + patchV3Survey( + appCurrentSurvey, + { targeting: { filters: attributeFilters } }, + "req_app_patch_4", + "org_1" + ) + ).rejects.toThrow(V3SurveyWritePermissionError); + + expect(setV3SurveySegmentFilters).not.toHaveBeenCalled(); + expect(prisma.survey.update).not.toHaveBeenCalled(); + }); + + test("rejects unknown trigger action class ids before writing", async () => { + vi.mocked(getActionClasses).mockResolvedValue([]); + + await expect( + patchV3Survey( + appCurrentSurvey, + { distribution: { triggers: [{ actionClassId: actionClass.id }] } }, + "req_app_patch_5", + "org_1" + ) + ).rejects.toThrow(V3SurveyReferenceValidationError); + + expect(prisma.survey.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/app/api/v3/surveys/patch.ts b/apps/web/app/api/v3/surveys/patch.ts index eb67914ee558..f8751ac1a5e0 100644 --- a/apps/web/app/api/v3/surveys/patch.ts +++ b/apps/web/app/api/v3/surveys/patch.ts @@ -3,19 +3,28 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import type { TSurvey } from "@formbricks/types/surveys/types"; +import { getActionClasses } from "@/lib/actionClass/service"; import { selectSurvey } from "@/lib/survey/service"; import { stripIsDraftFromBlocks, transformPrismaSurvey } from "@/lib/survey/utils"; +import { handleTriggerUpdates } from "@/modules/survey/lib/trigger-updates"; import { isSurveySchedulingDue, normalizeSurveyScheduling, reconcileDueSurveySchedules, } from "@/modules/survey/scheduling/lib/survey-scheduling"; +import { v3DistributionToScalars } from "./distribution"; import { type TV3SurveyLanguageRequest, ensureV3WorkspaceLanguages } from "./languages"; import { prepareV3SurveyPatchInput } from "./prepare"; import { V3SurveyReferenceValidationError } from "./reference-validation"; import type { TV3SurveyDocument } from "./schemas"; +import { + areV3SurveyTargetingFiltersEqual, + assertV3SurveyTargetingFilterReferences, + setV3SurveySegmentFilters, +} from "./targeting"; +import { resolveV3SurveyTriggers } from "./triggers"; import { getV3SurveyMediaInvalidParams } from "./validation"; -import { assertV3SurveyWritePermissions } from "./write-permissions"; +import { assertV3SurveyTargetingWritePermission, assertV3SurveyWritePermissions } from "./write-permissions"; function buildSurveyLanguageUpdate( currentSurvey: TSurvey, @@ -100,6 +109,59 @@ async function reconcilePersistedV3SurveyPatch({ return transformPrismaSurvey(reconciledSurvey); } +type TV3SegmentFilterWrite = { + segmentId: string; + filters: NonNullable["filters"]; +}; + +/** + * Apply the app-only distribution onto the survey update `data` (display scalars + the triggers diff) + * and return the segment-filter write to perform, if targeting actually changed. Trigger ids are + * validated first so an invalid id fails before any write. The segment write is RETURNED rather than + * executed so the caller can run it in the same transaction as the survey update. Entitlement for + * changed targeting is gated upstream in `patchV3Survey`. + */ +async function buildV3AppSurveyPatchWrites(params: { + currentSurvey: TSurvey; + document: TV3SurveyDocument; + data: Prisma.SurveyUpdateInput; +}): Promise { + const { currentSurvey, document, data } = params; + const distribution = document.distribution; + if (!distribution) { + return null; + } + + const actionClasses = await getActionClasses(currentSurvey.workspaceId); + const resolvedTriggers = resolveV3SurveyTriggers(distribution.triggers, actionClasses); + + Object.assign(data, v3DistributionToScalars(distribution)); + data.triggers = handleTriggerUpdates(resolvedTriggers, currentSurvey.triggers, actionClasses); + + const nextFilters = document.targeting?.filters ?? []; + const segmentId = currentSurvey.segment?.id; + const filtersChanged = !areV3SurveyTargetingFiltersEqual(currentSurvey.segment?.filters ?? [], nextFilters); + + if (!filtersChanged) { + return null; + } + // App surveys auto-create a private segment; if one is somehow missing we cannot persist the + // targeting change. Fail loudly instead of returning 200 while silently dropping the filters. + if (!segmentId) { + throw new V3SurveyReferenceValidationError([ + { + name: "targeting.filters", + reason: "Cannot apply contact targeting: this app survey has no segment to store filters on.", + }, + ]); + } + + // Validate attribute-key references on the changed filters before the write (mirrors trigger ids). + await assertV3SurveyTargetingFilterReferences(currentSurvey.workspaceId, nextFilters); + + return { segmentId, filters: nextFilters }; +} + export async function executeV3SurveyPatch(params: { currentSurvey: TSurvey; document: TV3SurveyDocument; @@ -120,24 +182,40 @@ export async function executeV3SurveyPatch(params: { status: document.status, }); + const data: Prisma.SurveyUpdateInput = { + name: document.name, + status: document.status, + metadata: document.metadata, + welcomeCard: document.welcomeCard, + blocks: stripIsDraftFromBlocks(document.blocks), + endings: document.endings, + hiddenFields: document.hiddenFields, + variables: document.variables, + closeOn: normalizedScheduling.closeOn, + publishOn: normalizedScheduling.publishOn, + languages: buildSurveyLanguageUpdate(currentSurvey, languages), + }; + + // App-only runtime/distribution settings (display scalars, triggers); also yields the segment + // targeting write to perform, if any. + const segmentFilterWrite = + currentSurvey.type === "app" + ? await buildV3AppSurveyPatchWrites({ currentSurvey, document, data }) + : null; + + const runSurveyUpdate = (client: Prisma.TransactionClient = prisma) => + client.survey.update({ where: { id: currentSurvey.id }, data, select: selectSurvey }); + try { - const persistedSurvey = await prisma.survey.update({ - where: { id: currentSurvey.id }, - data: { - name: document.name, - status: document.status, - metadata: document.metadata, - welcomeCard: document.welcomeCard, - blocks: stripIsDraftFromBlocks(document.blocks), - endings: document.endings, - hiddenFields: document.hiddenFields, - variables: document.variables, - closeOn: normalizedScheduling.closeOn, - publishOn: normalizedScheduling.publishOn, - languages: buildSurveyLanguageUpdate(currentSurvey, languages), - }, - select: selectSurvey, - }); + // Segment filters live on a separate row; when they change, write them in the SAME transaction as + // the survey update so the two can't diverge on a mid-write failure. Patches that don't touch + // targeting stay a single statement (no transaction overhead). + const persistedSurvey = segmentFilterWrite + ? await prisma.$transaction(async (tx) => { + await setV3SurveySegmentFilters(segmentFilterWrite.segmentId, segmentFilterWrite.filters, tx); + return runSurveyUpdate(tx); + }) + : await runSurveyUpdate(); return await reconcilePersistedV3SurveyPatch({ survey: transformPrismaSurvey(persistedSurvey), @@ -176,6 +254,8 @@ export async function patchV3Survey( organizationId ); + await assertV3SurveyTargetingWritePermission(currentSurvey, preparation.document, organizationId); + return await executeV3SurveyPatch({ currentSurvey, document: preparation.document, diff --git a/apps/web/app/api/v3/surveys/prepare.ts b/apps/web/app/api/v3/surveys/prepare.ts index 86ce42caf408..7719545f1bf9 100644 --- a/apps/web/app/api/v3/surveys/prepare.ts +++ b/apps/web/app/api/v3/surveys/prepare.ts @@ -1,5 +1,6 @@ import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types"; import type { InvalidParam } from "@/app/api/v3/lib/response"; +import { surveyToV3Distribution, surveyToV3Targeting } from "./distribution"; import { getV3SurveyDefaultLanguage, getV3SurveyLanguages } from "./language"; import { type TV3SurveyLanguageRequest, deriveV3SurveyLanguageRequests } from "./languages"; import { @@ -88,6 +89,12 @@ function buildDocumentFromSurvey( } const defaultLanguage = getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE); + // App surveys carry distribution/targeting; include them so the patch replacement-merge preserves + // unspecified runtime settings (omitting the whole object on a patch keeps the stored values). + const appFields = + survey.type === "app" + ? { distribution: surveyToV3Distribution(survey), targeting: surveyToV3Targeting(survey) } + : {}; const documentResult = createZV3SurveyDocumentBaseSchema({ allowInternalDefaultTranslationKey: true, allowedLanguageCodes, @@ -103,6 +110,7 @@ function buildDocumentFromSurvey( endings: survey.endings, hiddenFields: survey.hiddenFields, variables: survey.variables, + ...appFields, }); if (!documentResult.success) { @@ -191,9 +199,11 @@ export function prepareV3SurveyPatchInput( return currentDocument; } - const parsedPatch = createZV3PatchSurveyBodySchema(currentDocument.document.defaultLanguage, { - allowedLanguageCodes, - }).safeParse(input); + const parsedPatch = createZV3PatchSurveyBodySchema( + currentDocument.document.defaultLanguage, + { allowedLanguageCodes }, + survey.type + ).safeParse(input); if (!parsedPatch.success) { return invalidPreparation(formatV3ZodInvalidParams(parsedPatch.error, "data")); diff --git a/apps/web/app/api/v3/surveys/schemas.test.ts b/apps/web/app/api/v3/surveys/schemas.test.ts index 3c5cd6f6cea2..ffb85b4da8f3 100644 --- a/apps/web/app/api/v3/surveys/schemas.test.ts +++ b/apps/web/app/api/v3/surveys/schemas.test.ts @@ -361,6 +361,117 @@ describe("ZV3CreateSurveyBody", () => { expect(result.type).toBe("app"); }); + test("accepts an app survey with distribution + targeting and applies distribution defaults", () => { + const result = ZV3CreateSurveyBody.parse({ + ...validCreateBody, + type: "app", + distribution: { + displayOption: "respondMultiple", + recontactDays: 7, + triggers: [{ actionClassId: "claa1234567890123456789012" }], + }, + targeting: { filters: [] }, + }); + + expect(result.distribution).toMatchObject({ + displayOption: "respondMultiple", + recontactDays: 7, + displayPercentage: null, + displayLimit: null, + autoClose: null, + autoComplete: null, + delay: 0, + triggers: [{ actionClassId: "claa1234567890123456789012" }], + }); + expect(result.targeting).toEqual({ filters: [] }); + }); + + test("rejects distribution and targeting on link surveys", () => { + const result = ZV3CreateSurveyBody.safeParse({ + ...validCreateBody, + type: "link", + distribution: { displayOption: "displayOnce" }, + targeting: { filters: [] }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(formatV3ZodInvalidParams(result.error, "body")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "distribution", code: "unsupported_field" }), + expect.objectContaining({ name: "targeting", code: "unsupported_field" }), + ]) + ); + } + }); + + test("requires displayPercentage when displayOption is displaySome", () => { + const result = ZV3CreateSurveyBody.safeParse({ + ...validCreateBody, + type: "app", + distribution: { displayOption: "displaySome" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(formatV3ZodInvalidParams(result.error, "body")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "distribution.displayPercentage", + code: "missing_required_field", + }), + ]) + ); + } + }); + + test("rejects displayPercentage when displayOption is not displaySome", () => { + 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", + }), + ]) + ); + } + }); + + test("rejects unsupported fields inside distribution", () => { + const result = ZV3CreateSurveyBody.safeParse({ + ...validCreateBody, + type: "app", + distribution: { displayOption: "displayOnce", surprise: true }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(formatV3ZodInvalidParams(result.error, "body")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "distribution.surprise", code: "unsupported_field" }), + ]) + ); + } + }); + + test("rejects a malformed trigger action class id", () => { + const result = ZV3CreateSurveyBody.safeParse({ + ...validCreateBody, + type: "app", + distribution: { triggers: [{ actionClassId: "not-a-cuid" }] }, + }); + + expect(result.success).toBe(false); + }); + test("rejects malformed locale maps that do not include the default language", () => { const result = ZV3CreateSurveyBody.safeParse({ ...validCreateBody, @@ -652,4 +763,31 @@ describe("ZV3PatchSurveyBody", () => { expect.arrayContaining(["blocks.0.id", "variables.0.id"]) ); }); + + test("rejects distribution and targeting when patching a link survey", () => { + const result = createZV3PatchSurveyBodySchema("en-US", undefined, "link").safeParse({ + distribution: { displayOption: "displayOnce" }, + targeting: { filters: [] }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(formatV3ZodInvalidParams(result.error, "body")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "distribution", code: "unsupported_field" }), + expect.objectContaining({ name: "targeting", code: "unsupported_field" }), + ]) + ); + } + }); + + test("accepts distribution and targeting when patching an app survey", () => { + const parsed = createZV3PatchSurveyBodySchema("en-US", undefined, "app").parse({ + distribution: { displayOption: "displayMultiple" }, + targeting: { filters: [] }, + }); + + expect(parsed.distribution).toMatchObject({ displayOption: "displayMultiple" }); + expect(parsed.targeting).toEqual({ filters: [] }); + }); }); diff --git a/apps/web/app/api/v3/surveys/schemas.ts b/apps/web/app/api/v3/surveys/schemas.ts index 192a54a08305..22d7787f95de 100644 --- a/apps/web/app/api/v3/surveys/schemas.ts +++ b/apps/web/app/api/v3/surveys/schemas.ts @@ -1,7 +1,10 @@ import { createId } from "@paralleldrive/cuid2"; import { z } from "zod"; +import { ZSegmentFilters } from "@formbricks/types/segment"; import { ZSurveyBlocks } from "@formbricks/types/surveys/blocks"; import { + type TSurveyType, + ZSurveyDisplayOption, ZSurveyEndings, ZSurveyHiddenFields, ZSurveyMetadata, @@ -388,6 +391,8 @@ const ROOT_KEYS = new Set([ "endings", "hiddenFields", "variables", + "distribution", + "targeting", ]); const PATCH_ROOT_KEYS = new Set([ "name", @@ -399,7 +404,24 @@ const PATCH_ROOT_KEYS = new Set([ "endings", "hiddenFields", "variables", + "distribution", + "targeting", ]); +// Single source of truth for the app-survey distribution scalar columns. Shared with the +// distribution mappers (./distribution) so the field list can't drift across the read/write sites. +export const V3_DISTRIBUTION_SCALAR_KEYS = [ + "displayOption", + "displayPercentage", + "displayLimit", + "recontactDays", + "autoClose", + "autoComplete", + "delay", +] as const; +// App-survey distribution/targeting key sets (validated only when type === "app"). +const DISTRIBUTION_KEYS = new Set([...V3_DISTRIBUTION_SCALAR_KEYS, "triggers"]); +const TRIGGER_KEYS = new Set(["actionClassId"]); +const TARGETING_KEYS = new Set(["filters"]); const LANGUAGE_KEYS = new Set(["code", "default", "enabled"]); const WELCOME_CARD_KEYS = new Set([ "enabled", @@ -932,6 +954,22 @@ function validateEnding( ); } +function validateTrigger(value: unknown, path: string, issues: InvalidParam[]): void { + addMissingRequiredKeyIssues(value, ["actionClassId"], path, issues, "survey trigger"); + addUnknownKeyIssues(value, TRIGGER_KEYS, path, issues, "survey trigger"); +} + +function validateDistribution(value: unknown, path: string, issues: InvalidParam[]): void { + addUnknownKeyIssues(value, DISTRIBUTION_KEYS, path, issues, "survey distribution"); + if (isPlainObject(value) && Array.isArray(value.triggers)) { + value.triggers.forEach((trigger, index) => validateTrigger(trigger, `${path}.triggers.${index}`, issues)); + } +} + +function validateTargeting(value: unknown, path: string, issues: InvalidParam[]): void { + addUnknownKeyIssues(value, TARGETING_KEYS, path, issues, "survey targeting"); +} + function getUnsupportedV3SurveyDocumentFields( value: unknown, rootKeys: Set, @@ -939,7 +977,9 @@ function getUnsupportedV3SurveyDocumentFields( options?: TV3LanguageCompatibilityOptions ): InvalidParam[] { const issues: InvalidParam[] = []; - addUnknownKeyIssues(value, rootKeys, "", issues); + // Include the allowed top-level fields in the error so agents/clients can self-correct from the + // message alone (mirrors the per-element/block unknown-key errors). + addUnknownKeyIssues(value, rootKeys, "", issues, "the survey document"); if (!isPlainObject(value)) { return issues; @@ -982,6 +1022,13 @@ function getUnsupportedV3SurveyDocumentFields( ); } + if ("distribution" in value) { + validateDistribution(value.distribution, "distribution", issues); + } + if ("targeting" in value) { + validateTargeting(value.targeting, "targeting", issues); + } + return issues; } @@ -1045,6 +1092,88 @@ function addLanguageIssues( const ZV3SurveyName = z.string().trim().min(1, "Survey name is required"); const ZV3SurveyBlocks = ZSurveyBlocks.min(1, "At least one block is required"); +// App-survey trigger references an existing workspace action class by id. +// Existence/uniqueness is validated against workspace action classes at write time. +const ZV3SurveyTrigger = z.object({ actionClassId: z.cuid2() }).strict(); + +/** + * App-survey runtime/distribution settings. Scalar defaults mirror the Survey DB defaults so a + * provided `distribution` object fully determines the runtime config (top-level replacement semantics). + * Only honored when `type === "app"`; rejected for link surveys (see addAppDistributionIssues). + */ +const ZV3SurveyDistribution = z + .object({ + displayOption: ZSurveyDisplayOption.prefault("displayOnce"), + displayPercentage: z.number().min(0.01).max(100).nullable().prefault(null), + displayLimit: z.number().int().nonnegative().nullable().prefault(null), + recontactDays: z.number().int().nonnegative().nullable().prefault(null), + autoClose: z.number().int().nonnegative().nullable().prefault(null), + autoComplete: z.number().int().min(1, "Response limit must be greater than 0").nullable().prefault(null), + delay: z.number().int().nonnegative().prefault(0), + triggers: z.array(ZV3SurveyTrigger).prefault([]), + }) + .strict(); + +// App-survey contact targeting. `filters: []` means "show to everyone". +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. + */ +function addAppDistributionIssues( + survey: { + distribution?: z.infer; + targeting?: z.infer; + }, + surveyType: TSurveyType | undefined, + ctx: z.RefinementCtx +): void { + if (surveyType === "link") { + if (survey.distribution !== undefined) { + ctx.addIssue({ + code: "custom", + message: "'distribution' is only supported for app surveys (type: 'app')", + params: { code: "unsupported_field" }, + path: ["distribution"], + }); + } + if (survey.targeting !== undefined) { + ctx.addIssue({ + code: "custom", + message: "'targeting' is only supported for app surveys (type: 'app')", + params: { code: "unsupported_field" }, + path: ["targeting"], + }); + } + return; + } + + 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) { + ctx.addIssue({ + code: "custom", + message: "displayPercentage is only allowed when displayOption is 'displaySome'", + params: { code: "unsupported_field" }, + path: ["distribution", "displayPercentage"], + }); + } +} + function createV3SurveyDocumentShape(options?: TV3LanguageCompatibilityOptions) { return { name: ZV3SurveyName, @@ -1057,6 +1186,8 @@ function createV3SurveyDocumentShape(options?: TV3LanguageCompatibilityOptions) endings: ZSurveyEndings.prefault([]), hiddenFields: ZSurveyHiddenFields.prefault({ enabled: false }), variables: ZSurveyVariables.prefault([]), + distribution: ZV3SurveyDistribution.optional(), + targeting: ZV3SurveyTargeting.optional(), }; } @@ -1071,6 +1202,8 @@ function createV3SurveyPatchShape(options?: TV3LanguageCompatibilityOptions) { endings: ZSurveyEndings.optional(), hiddenFields: ZSurveyHiddenFields.optional(), variables: ZSurveyVariables.optional(), + distribution: ZV3SurveyDistribution.optional(), + targeting: ZV3SurveyTargeting.optional(), }; } @@ -1100,6 +1233,7 @@ const ZV3CreateSurveyBodyBase = z.preprocess( }) .strict() .superRefine(addLanguageIssues) + .superRefine((body, ctx) => addAppDistributionIssues(body, body.type, ctx)) ); export const ZV3CreateSurveyBody = z @@ -1111,7 +1245,11 @@ export const ZV3CreateSurveyBody = z }) .pipe(ZV3CreateSurveyBodyBase); -function createV3PatchSurveyBodyBase(defaultLanguage: string, options?: TV3LanguageCompatibilityOptions) { +function createV3PatchSurveyBodyBase( + defaultLanguage: string, + options?: TV3LanguageCompatibilityOptions, + surveyType?: TSurveyType +) { return z.preprocess( createV3SurveyDocumentNormalizer({ fallbackDefaultLanguage: defaultLanguage, @@ -1122,6 +1260,7 @@ function createV3PatchSurveyBodyBase(defaultLanguage: string, options?: TV3Langu .object(createV3SurveyPatchShape(options)) .strict() .superRefine((body, ctx) => addLanguageIssues({ ...body, defaultLanguage }, ctx)) + .superRefine((body, ctx) => addAppDistributionIssues(body, surveyType, ctx)) .refine((body) => Object.keys(body).length > 0, { message: "Request body must include at least one updatable field", }) @@ -1130,7 +1269,8 @@ function createV3PatchSurveyBodyBase(defaultLanguage: string, options?: TV3Langu export function createZV3PatchSurveyBodySchema( defaultLanguage = DEFAULT_V3_SURVEY_LANGUAGE, - options?: TV3LanguageCompatibilityOptions + options?: TV3LanguageCompatibilityOptions, + surveyType?: TSurveyType ) { return z .unknown() @@ -1144,7 +1284,7 @@ export function createZV3PatchSurveyBodySchema( addInvalidParamZodIssue(ctx, issue); } }) - .pipe(createV3PatchSurveyBodyBase(defaultLanguage, options)); + .pipe(createV3PatchSurveyBodyBase(defaultLanguage, options, surveyType)); } export const ZV3PatchSurveyBody = createZV3PatchSurveyBodySchema(); @@ -1184,3 +1324,6 @@ export type TV3SurveyDocument = z.infer; export type TV3CreateSurveyBody = z.infer; export type TV3PatchSurveyBody = z.infer; export type TV3SurveyValidationRequestBody = z.infer; +export type TV3SurveyDistribution = z.infer; +export type TV3SurveyTargeting = z.infer; +export type TV3SurveyTrigger = z.infer; diff --git a/apps/web/app/api/v3/surveys/serializers.test.ts b/apps/web/app/api/v3/surveys/serializers.test.ts index 11207a5578ee..067f3bd23a3c 100644 --- a/apps/web/app/api/v3/surveys/serializers.test.ts +++ b/apps/web/app/api/v3/surveys/serializers.test.ts @@ -98,6 +98,70 @@ const createLegacyHindiSurvey = (overrides: Partial = {}) => }) as unknown as TSurvey; describe("serializeV3SurveyResource", () => { + test("includes distribution and targeting for app surveys", () => { + const appSurvey = { + ...baseSurvey, + type: "app", + displayOption: "respondMultiple", + displayPercentage: 25, + displayLimit: 3, + recontactDays: 7, + autoClose: 30, + autoComplete: 100, + delay: 5, + triggers: [{ actionClass: { id: "claa1234567890123456789012", name: "Checkout" } }], + segment: { + id: "seg_1", + filters: [ + { + id: "f1", + connector: null, + resource: { + id: "r1", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }, + }, + ], + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(appSurvey); + + expect(resource.distribution).toEqual({ + displayOption: "respondMultiple", + displayPercentage: 25, + displayLimit: 3, + recontactDays: 7, + autoClose: 30, + autoComplete: 100, + delay: 5, + triggers: [{ actionClassId: "claa1234567890123456789012" }], + }); + expect(resource.targeting).toEqual({ + filters: [ + { + id: "f1", + connector: null, + resource: { + id: "r1", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }, + }, + ], + }); + }); + + test("omits distribution and targeting for link surveys", () => { + const resource = serializeV3SurveyResource(baseSurvey); + + expect(resource).not.toHaveProperty("distribution"); + expect(resource).not.toHaveProperty("targeting"); + }); + test("returns multilingual fields using emitted survey language codes", () => { const resource = serializeV3SurveyResource(baseSurvey); diff --git a/apps/web/app/api/v3/surveys/serializers.ts b/apps/web/app/api/v3/surveys/serializers.ts index 127018b25446..bd86b590f703 100644 --- a/apps/web/app/api/v3/surveys/serializers.ts +++ b/apps/web/app/api/v3/surveys/serializers.ts @@ -1,5 +1,6 @@ import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types"; import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys"; +import { surveyToV3Distribution, surveyToV3Targeting } from "./distribution"; import { isInternalI18nString, isPlainObject } from "./guards"; import { type TV3SurveyResolverLanguage, @@ -236,5 +237,10 @@ export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { l endings: serializeValue(survey.endings), hiddenFields: survey.hiddenFields, variables: survey.variables, + // App-only runtime/distribution + targeting, via the shared survey→public mappers. Omitted + // entirely for link surveys to keep the contract clean. + ...(survey.type === "app" + ? { distribution: surveyToV3Distribution(survey), targeting: surveyToV3Targeting(survey) } + : {}), }; } diff --git a/apps/web/app/api/v3/surveys/targeting.test.ts b/apps/web/app/api/v3/surveys/targeting.test.ts new file mode 100644 index 000000000000..9099a3ef0cfa --- /dev/null +++ b/apps/web/app/api/v3/surveys/targeting.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import type { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; +import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; +import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { V3SurveyReferenceValidationError } from "./reference-validation"; +import type { TV3SurveyTargeting } from "./schemas"; +import { + areV3SurveyTargetingFiltersEqual, + assertV3SurveyTargetingFilterReferences, + resolveV3ContactsEntitlement, + setV3SurveySegmentFilters, +} from "./targeting"; + +vi.mock("server-only", () => ({})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + segment: { + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByWorkspaceId: vi.fn(), +})); + +vi.mock("@/modules/ee/contacts/lib/contact-attribute-keys", () => ({ + getContactAttributeKeys: vi.fn(), +})); + +vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({ + getSegments: vi.fn(), +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsContactsEnabled: vi.fn(), +})); + +const EMPTY_FILTERS: TV3SurveyTargeting["filters"] = []; +type ResolvedOrganization = Awaited>; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveV3ContactsEntitlement", () => { + test("uses the supplied organizationId without a workspace lookup", async () => { + vi.mocked(getIsContactsEnabled).mockResolvedValueOnce(true); + + const result = await resolveV3ContactsEntitlement("ws_1", "org_1"); + + expect(result).toEqual({ resolvedOrganizationId: "org_1", isContactsEnabled: true }); + expect(getOrganizationByWorkspaceId).not.toHaveBeenCalled(); + expect(getIsContactsEnabled).toHaveBeenCalledWith("org_1"); + }); + + test("resolves the organization from the workspace when no id is supplied", async () => { + vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce({ + id: "org_2", + } as unknown as ResolvedOrganization); + vi.mocked(getIsContactsEnabled).mockResolvedValueOnce(false); + + const result = await resolveV3ContactsEntitlement("ws_2"); + + expect(getOrganizationByWorkspaceId).toHaveBeenCalledWith("ws_2"); + expect(getIsContactsEnabled).toHaveBeenCalledWith("org_2"); + expect(result).toEqual({ resolvedOrganizationId: "org_2", isContactsEnabled: false }); + }); + + test("returns a null org and disabled flag when the workspace has no organization", async () => { + vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(null); + + const result = await resolveV3ContactsEntitlement("ws_3"); + + expect(result).toEqual({ resolvedOrganizationId: null, isContactsEnabled: false }); + expect(getIsContactsEnabled).not.toHaveBeenCalled(); + }); +}); + +describe("areV3SurveyTargetingFiltersEqual", () => { + test("treats null/undefined and an empty array as equal", () => { + expect(areV3SurveyTargetingFiltersEqual(null, [])).toBe(true); + expect(areV3SurveyTargetingFiltersEqual(undefined, [])).toBe(true); + expect(areV3SurveyTargetingFiltersEqual([], null)).toBe(true); + }); + + test("compares arrays positionally", () => { + const filters = [{ id: "1", connector: null }]; + expect(areV3SurveyTargetingFiltersEqual(filters, [{ id: "1", connector: null }])).toBe(true); + expect( + areV3SurveyTargetingFiltersEqual(filters, [ + { id: "1", connector: null }, + { id: "2", connector: "and" }, + ]) + ).toBe(false); + expect(areV3SurveyTargetingFiltersEqual(filters, [{ id: "2", connector: null }])).toBe(false); + }); + + test("compares object keys order-independently", () => { + expect(areV3SurveyTargetingFiltersEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + // different key count + expect(areV3SurveyTargetingFiltersEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + // same key count, different key name (exercises the Object.hasOwn guard) + expect(areV3SurveyTargetingFiltersEqual({ a: 1, b: 2 }, { a: 1, c: 2 })).toBe(false); + }); + + test("recurses into nested filter structures", () => { + const left = [{ resource: { root: { type: "attribute", contactAttributeKey: "email" } } }]; + const reordered = [{ resource: { root: { contactAttributeKey: "email", type: "attribute" } } }]; + const changed = [{ resource: { root: { type: "attribute", contactAttributeKey: "plan" } } }]; + + expect(areV3SurveyTargetingFiltersEqual(left, reordered)).toBe(true); + expect(areV3SurveyTargetingFiltersEqual(left, changed)).toBe(false); + }); + + test("handles primitives and type mismatches", () => { + expect(areV3SurveyTargetingFiltersEqual("same", "same")).toBe(true); + expect(areV3SurveyTargetingFiltersEqual(1, 2)).toBe(false); + expect(areV3SurveyTargetingFiltersEqual({ a: 1 }, [1])).toBe(false); + expect(areV3SurveyTargetingFiltersEqual([1], { a: 1 })).toBe(false); + }); +}); + +describe("setV3SurveySegmentFilters", () => { + test("updates the segment via the default prisma client", async () => { + await setV3SurveySegmentFilters("seg_1", EMPTY_FILTERS); + + expect(prisma.segment.update).toHaveBeenCalledWith({ + where: { id: "seg_1" }, + data: { filters: EMPTY_FILTERS }, + }); + }); + + test("writes through a provided transaction client instead of the shared prisma client", async () => { + const tx = { segment: { update: vi.fn() } }; + + await setV3SurveySegmentFilters("seg_2", EMPTY_FILTERS, tx as unknown as Prisma.TransactionClient); + + expect(tx.segment.update).toHaveBeenCalledWith({ + where: { id: "seg_2" }, + data: { filters: EMPTY_FILTERS }, + }); + expect(prisma.segment.update).not.toHaveBeenCalled(); + }); + + test("maps known Prisma errors to a DatabaseError", async () => { + vi.mocked(prisma.segment.update).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("segment update failed", { + code: "P2025", + clientVersion: "test", + }) + ); + + await expect(setV3SurveySegmentFilters("seg_3", EMPTY_FILTERS)).rejects.toThrow(DatabaseError); + }); + + test("rethrows non-Prisma errors unchanged", async () => { + const unexpected = new Error("connection lost"); + vi.mocked(prisma.segment.update).mockRejectedValueOnce(unexpected); + + await expect(setV3SurveySegmentFilters("seg_4", EMPTY_FILTERS)).rejects.toBe(unexpected); + }); +}); + +type TFilterTree = TV3SurveyTargeting["filters"]; + +const leafNode = (id: string, root: Record) => ({ + id, + connector: null, + resource: { id: `${id}_res`, root, qualifier: { operator: "equals" }, value: "x" }, +}); + +const attributeFilterNode = (id: string, contactAttributeKey: string) => + leafNode(id, { type: "attribute", contactAttributeKey }); +const personFilterNode = (id: string, personIdentifier: string) => + leafNode(id, { type: "person", personIdentifier }); +const segmentFilterNode = (id: string, segmentId: string) => leafNode(id, { type: "segment", segmentId }); +const deviceFilterNode = (id: string, value: unknown, deviceType: string = "phone") => ({ + id, + connector: null, + resource: { + id: `${id}_res`, + root: { type: "device", deviceType }, + qualifier: { operator: "equals" }, + value, + }, +}); + +const mockAttributeKeys = (keys: string[]): void => { + vi.mocked(getContactAttributeKeys).mockResolvedValueOnce( + keys.map((key) => ({ key })) as TContactAttributeKey[] + ); +}; + +const mockSegments = (ids: string[]): void => { + vi.mocked(getSegments).mockResolvedValueOnce( + ids.map((id) => ({ id })) as Awaited> + ); +}; + +describe("assertV3SurveyTargetingFilterReferences", () => { + test("performs no workspace lookup when filters need none", async () => { + const validDeviceOnly = [deviceFilterNode("f1", "phone")] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", EMPTY_FILTERS)).resolves.toBeUndefined(); + await expect(assertV3SurveyTargetingFilterReferences("ws_1", validDeviceOnly)).resolves.toBeUndefined(); + expect(getContactAttributeKeys).not.toHaveBeenCalled(); + expect(getSegments).not.toHaveBeenCalled(); + }); + + test("rejects a device filter whose value is not a known device, without any lookup", async () => { + const filters = [deviceFilterNode("f1", "tablet")] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).rejects.toMatchObject({ + invalidParams: [ + { + name: "targeting.filters.0.resource.value", + reason: "Unsupported device 'tablet'; expected one of: phone, desktop", + code: "invalid_reference", + identifier: "tablet", + }, + ], + }); + expect(getContactAttributeKeys).not.toHaveBeenCalled(); + expect(getSegments).not.toHaveBeenCalled(); + }); + + test("rejects a garbage root.deviceType even when value is a known device", async () => { + const filters = [deviceFilterNode("f1", "phone", "tablet")] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).rejects.toMatchObject({ + invalidParams: [ + { + name: "targeting.filters.0.resource.root.deviceType", + reason: "Unsupported device 'tablet'; expected one of: phone, desktop", + code: "invalid_reference", + identifier: "tablet", + }, + ], + }); + }); + + test("resolves when every referenced attribute key exists in the workspace", async () => { + mockAttributeKeys(["plan", "role"]); + const filters = [ + attributeFilterNode("f1", "plan"), + attributeFilterNode("f2", "role"), + ] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).resolves.toBeUndefined(); + expect(getContactAttributeKeys).toHaveBeenCalledWith("ws_1"); + }); + + test("throws invalid_reference with the filter path for an unknown attribute key", async () => { + mockAttributeKeys(["plan"]); + const filters = [attributeFilterNode("f1", "made_up_key")] as unknown as TFilterTree; + + try { + await assertV3SurveyTargetingFilterReferences("ws_1", filters); + throw new Error("expected assertion to throw"); + } catch (error) { + expect(error).toBeInstanceOf(V3SurveyReferenceValidationError); + expect((error as V3SurveyReferenceValidationError).invalidParams).toEqual([ + { + name: "targeting.filters.0.resource.root.contactAttributeKey", + reason: "Contact attribute key 'made_up_key' was not found in this workspace", + code: "invalid_reference", + identifier: "made_up_key", + }, + ]); + } + }); + + test("recurses into nested filter groups and reports the nested path", async () => { + mockAttributeKeys(["plan"]); + const filters = [ + { + id: "group1", + connector: null, + resource: [attributeFilterNode("f1", "unknown_nested")], + }, + ] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).rejects.toMatchObject({ + invalidParams: [ + { + name: "targeting.filters.0.resource.0.resource.root.contactAttributeKey", + identifier: "unknown_nested", + code: "invalid_reference", + }, + ], + }); + }); + + test("validates a person 'userId' filter against the userId attribute key", async () => { + mockAttributeKeys(["userId"]); + const ok = [personFilterNode("f1", "userId")] as unknown as TFilterTree; + await expect(assertV3SurveyTargetingFilterReferences("ws_1", ok)).resolves.toBeUndefined(); + + mockAttributeKeys([]); + const missing = [personFilterNode("f1", "userId")] as unknown as TFilterTree; + await expect(assertV3SurveyTargetingFilterReferences("ws_1", missing)).rejects.toMatchObject({ + invalidParams: [ + { + name: "targeting.filters.0.resource.root.personIdentifier", + identifier: "userId", + code: "invalid_reference", + }, + ], + }); + }); + + test("rejects an unsupported person identifier without any lookup", async () => { + const filters = [personFilterNode("f1", "email")] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).rejects.toMatchObject({ + invalidParams: [ + { + name: "targeting.filters.0.resource.root.personIdentifier", + reason: "Unsupported person identifier 'email'; only 'userId' is supported", + code: "invalid_reference", + identifier: "email", + }, + ], + }); + expect(getContactAttributeKeys).not.toHaveBeenCalled(); + expect(getSegments).not.toHaveBeenCalled(); + }); + + test("resolves a segment filter that references a workspace segment", async () => { + mockSegments(["seg_known"]); + const filters = [segmentFilterNode("f1", "seg_known")] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).resolves.toBeUndefined(); + expect(getSegments).toHaveBeenCalledWith("ws_1"); + }); + + test("rejects a segment filter referencing an unknown or foreign segment", async () => { + mockSegments(["seg_known"]); + const filters = [segmentFilterNode("f1", "seg_other_workspace")] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).rejects.toMatchObject({ + invalidParams: [ + { + name: "targeting.filters.0.resource.root.segmentId", + reason: "Segment 'seg_other_workspace' was not found in this workspace", + code: "invalid_reference", + identifier: "seg_other_workspace", + }, + ], + }); + }); +}); diff --git a/apps/web/app/api/v3/surveys/targeting.ts b/apps/web/app/api/v3/surveys/targeting.ts new file mode 100644 index 000000000000..68d4ede2b43c --- /dev/null +++ b/apps/web/app/api/v3/surveys/targeting.ts @@ -0,0 +1,280 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import { DatabaseError } from "@formbricks/types/errors"; +import type { + TSegmentAttributeFilter, + TSegmentDeviceFilter, + TSegmentPersonFilter, + TSegmentSegmentFilter, +} from "@formbricks/types/segment"; +import type { InvalidParam } from "@/app/api/v3/lib/response"; +import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; +import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; +import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { V3SurveyReferenceValidationError } from "./reference-validation"; +import type { TV3SurveyTargeting } from "./schemas"; + +type TV3SurveyFilters = TV3SurveyTargeting["filters"]; + +/** Shared message for the (enterprise) contacts/targeting entitlement, thrown with caller-specific error classes. */ +export const V3_CONTACTS_NOT_ENABLED_MESSAGE = + "Contact targeting (segments) is not enabled for this organization. Upgrade to target app surveys by contact attributes."; + +/** + * Resolve whether contact targeting (the enterprise Contacts feature) is enabled for a workspace's + * organization. Returns the resolved org id alongside the flag so each caller can throw its own typed + * permission error (create vs patch surface differently). `resolvedOrganizationId` is null when the + * organization cannot be resolved. + */ +export async function resolveV3ContactsEntitlement( + workspaceId: string, + organizationId?: string +): Promise<{ resolvedOrganizationId: string | null; isContactsEnabled: boolean }> { + const resolvedOrganizationId = + organizationId ?? (await getOrganizationByWorkspaceId(workspaceId))?.id ?? null; + if (!resolvedOrganizationId) { + return { resolvedOrganizationId: null, isContactsEnabled: false }; + } + + const isContactsEnabled = await getIsContactsEnabled(resolvedOrganizationId); + return { resolvedOrganizationId, isContactsEnabled }; +} + +/** + * Order-insensitive deep equality for JSON values. Object keys are compared by name (order does not + * matter); arrays are compared positionally (filter order is meaningful). Used to detect whether a + * patch actually changes the targeting filters so the contacts entitlement gate and segment write + * only run on real changes. + */ +function jsonDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + if (typeof a !== typeof b || a === null || b === null) { + return false; + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((item, index) => jsonDeepEqual(item, b[index])); + } + if (typeof a === "object") { + const aRecord = a as Record; + const bRecord = b as Record; + const aKeys = Object.keys(aRecord); + const bKeys = Object.keys(bRecord); + if (aKeys.length !== bKeys.length) { + return false; + } + return aKeys.every((key) => Object.hasOwn(bRecord, key) && jsonDeepEqual(aRecord[key], bRecord[key])); + } + return false; +} + +export function areV3SurveyTargetingFiltersEqual(a: unknown, b: unknown): boolean { + return jsonDeepEqual(a ?? [], b ?? []); +} + +/** + * Persist app-survey contact targeting onto the survey's (auto-created) segment. Writes the segment + * row directly — mirroring updateSurveyInternal — so empty filters ("show to everyone") are supported, + * which the EE updateSegment service rejects. Entitlement is gated by the caller before invoking this. + * + * Pass a transaction client (`tx`) to write atomically alongside the survey update; defaults to the + * shared prisma client for standalone writes (e.g. on create). + */ +export async function setV3SurveySegmentFilters( + segmentId: string, + filters: TV3SurveyFilters, + client: Prisma.TransactionClient = prisma +): Promise { + try { + await client.segment.update({ + where: { id: segmentId }, + data: { filters }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +} + +// Allowed device-filter values; the evaluator compares the contact's runtime device against the +// filter's `value`, so any other value can never match. +const V3_TARGETING_DEVICE_VALUES = ["phone", "desktop"] as const; + +// What a targeting leaf filter must satisfy to be valid, tagged by kind. `attributeKey` covers +// `attribute` filters AND `person` filters (personIdentifier "userId" resolves to the "userId" +// attribute key at evaluation time); `segment` covers segment-membership filters. The remaining kinds +// are static rejections that need no workspace lookup: `unsupportedPersonIdentifier` (a person filter +// whose identifier can never match) and `invalidDeviceValue` (a device filter whose value isn't a +// known device). +type TV3TargetingFilterReference = { + kind: "attributeKey" | "segment" | "unsupportedPersonIdentifier" | "invalidDeviceValue"; + value: string; + path: string; +}; + +/** + * Walk the (possibly nested) targeting filter tree once and return everything a leaf filter must + * satisfy to be valid, tagged by kind, each with its JSON path so issues can be reported per-filter. + */ +function collectV3TargetingFilterReferences( + filters: TV3SurveyFilters, + basePath: string +): TV3TargetingFilterReference[] { + return filters.flatMap((node, index) => { + const resourcePath = `${basePath}.${index}.resource`; + const { resource } = node; + + // A node's resource is either a nested filter group or a single leaf condition. + if (Array.isArray(resource)) { + return collectV3TargetingFilterReferences(resource, resourcePath); + } + + // root.type is a nested discriminant TS doesn't narrow on; cast as the segment-filter consumers do. + const rootPath = `${resourcePath}.root`; + switch (resource.root.type) { + case "attribute": + return [ + { + kind: "attributeKey" as const, + value: (resource as TSegmentAttributeFilter).root.contactAttributeKey, + path: `${rootPath}.contactAttributeKey`, + }, + ]; + case "person": { + // Only "userId" resolves (to the "userId" attribute key); any other identifier silently + // matches nothing at evaluation time, so reject it instead of persisting a dead filter. + const { personIdentifier } = (resource as TSegmentPersonFilter).root; + return [ + { + kind: + personIdentifier === "userId" + ? ("attributeKey" as const) + : ("unsupportedPersonIdentifier" as const), + value: personIdentifier, + path: `${rootPath}.personIdentifier`, + }, + ]; + } + case "segment": + return [ + { + kind: "segment" as const, + value: (resource as TSegmentSegmentFilter).root.segmentId, + path: `${rootPath}.segmentId`, + }, + ]; + case "device": { + // The evaluator matches on `value`; `root.deviceType` is the field the spec presents (the UI + // keeps the two in sync). Validate BOTH against the known devices so neither can hold garbage. + // value can be a number/tuple via the shared schema; stringify for the message. + const deviceFilter = resource as TSegmentDeviceFilter; + // Widen to unknown[] so includes() accepts the unknown candidate (value may be a number/tuple); + // a non-matching candidate just returns false, same as the prior per-element === check. + const isKnownDevice = (candidate: unknown): boolean => + (V3_TARGETING_DEVICE_VALUES as readonly unknown[]).includes(candidate); + const deviceIssues: (TV3TargetingFilterReference | null)[] = [ + isKnownDevice(deviceFilter.value) + ? null + : { + kind: "invalidDeviceValue", + value: + typeof deviceFilter.value === "string" + ? deviceFilter.value + : JSON.stringify(deviceFilter.value), + path: `${resourcePath}.value`, + }, + isKnownDevice(deviceFilter.root.deviceType) + ? null + : { + kind: "invalidDeviceValue", + value: deviceFilter.root.deviceType, + path: `${rootPath}.deviceType`, + }, + ]; + return deviceIssues.filter((issue): issue is TV3TargetingFilterReference => issue !== null); + } + default: + return []; + } + }); +} + +/** + * Validate that every app-survey targeting filter can resolve — contact-attribute keys (incl. the + * "userId" person identifier) and referenced segments must exist in the workspace, unsupported person + * identifiers are rejected, and device values must be a known device. Mirrors trigger action-class + * validation (see resolveV3SurveyTriggers): each problem becomes a structured `invalid_reference` param + * the v3 layer maps to a 422. Workspace lookups are skipped when no filter needs them. + */ +export async function assertV3SurveyTargetingFilterReferences( + workspaceId: string, + filters: TV3SurveyFilters +): Promise { + const references = collectV3TargetingFilterReferences(filters, "targeting.filters"); + + if (references.length === 0) { + return; + } + + const invalidParams: InvalidParam[] = []; + + // Static rejections that can never match — no workspace lookup needed. + for (const reference of references.filter((ref) => ref.kind === "unsupportedPersonIdentifier")) { + invalidParams.push({ + name: reference.path, + reason: `Unsupported person identifier '${reference.value}'; only 'userId' is supported`, + code: "invalid_reference", + identifier: reference.value, + }); + } + for (const reference of references.filter((ref) => ref.kind === "invalidDeviceValue")) { + invalidParams.push({ + name: reference.path, + reason: `Unsupported device '${reference.value}'; expected one of: ${V3_TARGETING_DEVICE_VALUES.join(", ")}`, + code: "invalid_reference", + identifier: reference.value, + }); + } + + const attributeKeyRefs = references.filter((ref) => ref.kind === "attributeKey"); + if (attributeKeyRefs.length > 0) { + const attributeKeys = await getContactAttributeKeys(workspaceId); + const knownKeys = new Set(attributeKeys.map((attributeKey) => attributeKey.key)); + for (const reference of attributeKeyRefs.filter((ref) => !knownKeys.has(ref.value))) { + invalidParams.push({ + name: reference.path, + reason: `Contact attribute key '${reference.value}' was not found in this workspace`, + code: "invalid_reference", + identifier: reference.value, + }); + } + } + + const segmentRefs = references.filter((ref) => ref.kind === "segment"); + if (segmentRefs.length > 0) { + // Scope to the workspace's own segments — `getSegment(id)` is global, so a bare existence check + // would accept (and store) a reference to another workspace's segment. + const segments = await getSegments(workspaceId); + const knownSegmentIds = new Set(segments.map((segment) => segment.id)); + for (const reference of segmentRefs.filter((ref) => !knownSegmentIds.has(ref.value))) { + invalidParams.push({ + name: reference.path, + reason: `Segment '${reference.value}' was not found in this workspace`, + code: "invalid_reference", + identifier: reference.value, + }); + } + } + + if (invalidParams.length > 0) { + throw new V3SurveyReferenceValidationError(invalidParams); + } +} diff --git a/apps/web/app/api/v3/surveys/triggers.ts b/apps/web/app/api/v3/surveys/triggers.ts new file mode 100644 index 000000000000..9f357f406ca6 --- /dev/null +++ b/apps/web/app/api/v3/surveys/triggers.ts @@ -0,0 +1,54 @@ +import "server-only"; +import type { TActionClass } from "@formbricks/types/action-classes"; +import type { InvalidParam } from "@/app/api/v3/lib/response"; +import { V3SurveyReferenceValidationError } from "./reference-validation"; +import type { TV3SurveyTrigger } from "./schemas"; + +/** + * Validate trigger action-class ids against the workspace's action classes and resolve them to the + * full action-class objects that the survey service expects (ZSurveyCreateInput / handleTriggerUpdates + * both ultimately read `actionClass.id`, but ZSurveyCreateInput validates the full ZActionClass shape). + * Throws structured `invalid_params` on unknown or duplicate ids; the v3 layer maps this to a 422. + */ +export function resolveV3SurveyTriggers( + triggers: TV3SurveyTrigger[], + actionClasses: TActionClass[] +): { actionClass: TActionClass }[] { + const invalidParams: InvalidParam[] = []; + const seen = new Set(); + const resolved: { actionClass: TActionClass }[] = []; + + triggers.forEach((trigger, index) => { + const name = `distribution.triggers.${index}.actionClassId`; + + if (seen.has(trigger.actionClassId)) { + invalidParams.push({ + name, + reason: `Duplicate trigger action class id '${trigger.actionClassId}'`, + code: "duplicate_identifier", + identifier: trigger.actionClassId, + }); + return; + } + seen.add(trigger.actionClassId); + + const actionClass = actionClasses.find((candidate) => candidate.id === trigger.actionClassId); + if (!actionClass) { + invalidParams.push({ + name, + reason: `Action class '${trigger.actionClassId}' was not found in this workspace`, + code: "invalid_reference", + identifier: trigger.actionClassId, + }); + return; + } + + resolved.push({ actionClass }); + }); + + if (invalidParams.length > 0) { + throw new V3SurveyReferenceValidationError(invalidParams); + } + + return resolved; +} diff --git a/apps/web/app/api/v3/surveys/write-permissions.ts b/apps/web/app/api/v3/surveys/write-permissions.ts index 8c7eef5d6fd2..edcb69609df6 100644 --- a/apps/web/app/api/v3/surveys/write-permissions.ts +++ b/apps/web/app/api/v3/surveys/write-permissions.ts @@ -1,9 +1,15 @@ import "server-only"; import type { TSurveyBlock } from "@formbricks/types/surveys/blocks"; -import type { TSurveyEnding } from "@formbricks/types/surveys/types"; +import type { TSurvey, TSurveyEnding } from "@formbricks/types/surveys/types"; import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; import { getElementsFromBlocks } from "@/lib/survey/utils"; import { getExternalUrlsPermission } from "@/modules/survey/lib/permission"; +import type { TV3SurveyDocument } from "./schemas"; +import { + V3_CONTACTS_NOT_ENABLED_MESSAGE, + areV3SurveyTargetingFiltersEqual, + resolveV3ContactsEntitlement, +} from "./targeting"; type TV3SurveyWritePermissionInput = { workspaceId: string; @@ -90,3 +96,36 @@ export async function assertV3SurveyWritePermissions( ); } } + +/** + * Contact targeting (segment filters) is an enterprise feature. Only gate when the patch actually + * changes the stored filters, so an unentitled organization can still patch other app-survey fields. + */ +export async function assertV3SurveyTargetingWritePermission( + currentSurvey: TSurvey, + document: TV3SurveyDocument, + organizationId?: string +): Promise { + if (currentSurvey.type !== "app") { + return; + } + + const currentFilters = currentSurvey.segment?.filters ?? []; + const nextFilters = document.targeting?.filters ?? []; + if (areV3SurveyTargetingFiltersEqual(currentFilters, nextFilters)) { + return; + } + + const { resolvedOrganizationId, isContactsEnabled } = await resolveV3ContactsEntitlement( + currentSurvey.workspaceId, + organizationId + ); + if (!resolvedOrganizationId) { + throw new V3SurveyWritePermissionError( + `Unable to verify contact targeting permissions for workspaceId: ${currentSurvey.workspaceId}` + ); + } + if (!isContactsEnabled) { + throw new V3SurveyWritePermissionError(V3_CONTACTS_NOT_ENABLED_MESSAGE); + } +} diff --git a/apps/web/lib/survey/service.scheduling.test.ts b/apps/web/lib/survey/service.scheduling.test.ts index d5f9084dea99..7457f91ff2b7 100644 --- a/apps/web/lib/survey/service.scheduling.test.ts +++ b/apps/web/lib/survey/service.scheduling.test.ts @@ -59,6 +59,10 @@ describe("survey service scheduling", () => { vi.mocked(getActionClasses).mockResolvedValue([mockActionClass] as never); vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({ id: "org123" } as never); mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined); + // createSurvey now wraps its core writes in prisma.$transaction; run the callback with the same + // mocked client so per-test prisma.survey/segment mocks still apply inside the transaction. + vi.mocked(prisma.$transaction).mockImplementation(((callback: (tx: typeof prisma) => Promise) => + callback(prisma)) as typeof prisma.$transaction); }); afterEach(() => { diff --git a/apps/web/lib/survey/service.test.ts b/apps/web/lib/survey/service.test.ts index d10aed9fd9a7..05bdbd135190 100644 --- a/apps/web/lib/survey/service.test.ts +++ b/apps/web/lib/survey/service.test.ts @@ -10,7 +10,7 @@ import { ResourceNotFoundError, ValidationError, } from "@formbricks/types/errors"; -import { TSegment } from "@formbricks/types/segment"; +import { TBaseFilters, TSegment } from "@formbricks/types/segment"; import { TSurveyFollowUp } from "@formbricks/types/surveys/follow-up"; import { TSurvey, TSurveyCreateInput, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { getActionClasses } from "@/lib/actionClass/service"; @@ -58,6 +58,10 @@ vi.mock("@/lib/actionClass/service", () => ({ beforeEach(() => { prisma.survey.count.mockResolvedValue(1); + // createSurvey now wraps its core writes in prisma.$transaction; run the callback with the same + // mocked client so per-test prisma.survey/segment mocks still apply inside the transaction. + vi.mocked(prisma.$transaction).mockImplementation(((callback: (tx: typeof prisma) => Promise) => + callback(prisma)) as typeof prisma.$transaction); }); describe("evaluateLogic with mockSurveyWithLogic", () => { @@ -686,6 +690,44 @@ describe("Tests for createSurvey", () => { expect(prisma.survey.update).toHaveBeenCalled(); }); + test("seeds the private segment with the provided filters for app surveys", async () => { + vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput); + prisma.survey.create.mockResolvedValueOnce({ + ...mockSurveyOutput, + type: "app", + }); + prisma.segment.create.mockResolvedValueOnce({ + id: "segment-123", + workspaceId: mockWorkspaceId, + title: mockSurveyOutput.id, + isPrivate: true, + filters: [], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as TSegment); + + const filters = [ + { + id: "clf01234567890123456789012", + connector: null, + resource: { + id: "clf11234567890123456789012", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }, + }, + ] as unknown as TBaseFilters; + + await createSurvey(mockWorkspaceId, { ...mockCreateSurveyInput, type: "app" }, filters); + + expect(prisma.segment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ filters, isPrivate: true }), + }) + ); + }); + test("creates survey with follow-ups", async () => { vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput); const followUp = { diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 32448780288c..80cb016f2688 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -5,7 +5,7 @@ import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { ZSegmentFilters } from "@formbricks/types/segment"; +import { TBaseFilters, ZSegmentFilters } from "@formbricks/types/segment"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types"; import { @@ -650,7 +650,11 @@ const assertSurveySegmentBelongsToWorkspace = async ( } }; -export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreateInput): Promise => { +export const createSurvey = async ( + workspaceId: string, + surveyBody: TSurveyCreateInput, + privateSegmentFilters: TBaseFilters = [] +): Promise => { const [parsedWorkspaceId, parsedSurveyBody] = validateInputs( [workspaceId, ZId], [surveyBody, ZSurveyCreateInput] @@ -703,46 +707,52 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat throw new ResourceNotFoundError("Organization", null); } - const survey = await prisma.survey.create({ - data: { - ...data, - workspace: { - connect: { - id: parsedWorkspaceId, - }, - }, - }, - select: selectSurvey, - }); - - // if the survey created is an "app" survey, we also create a private segment for it. - if (survey.type === "app") { - const newSegment = await prisma.segment.create({ + // Create the survey and — for app surveys — its private targeting segment atomically. The survey, + // the segment (seeded with any caller-supplied filters), and the segment connection must all land + // or none, so a mid-write failure can't leave a survey with missing or partial targeting. + const survey = await prisma.$transaction(async (tx) => { + const createdSurvey = await tx.survey.create({ data: { - title: survey.id, - filters: [], - isPrivate: true, + ...data, workspace: { connect: { id: parsedWorkspaceId, }, }, }, + select: selectSurvey, }); - await prisma.survey.update({ - where: { - id: survey.id, - }, - data: { - segment: { - connect: { - id: newSegment.id, + if (createdSurvey.type === "app") { + const newSegment = await tx.segment.create({ + data: { + title: createdSurvey.id, + filters: privateSegmentFilters, + isPrivate: true, + workspace: { + connect: { + id: parsedWorkspaceId, + }, }, }, - }, - }); - } + }); + + await tx.survey.update({ + where: { + id: createdSurvey.id, + }, + data: { + segment: { + connect: { + id: newSegment.id, + }, + }, + }, + }); + } + + return createdSurvey; + }); // TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration // @ts-expect-error diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 35abcaf67bb7..634f32c3cc3b 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -2682,6 +2682,7 @@ "ai_not_available": "KI-Umfragenerstellung ist nicht verfügbar.", "ai_not_enabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.", "ai_not_in_plan": "KI-Umfragenerstellung ist in deinem aktuellen Tarif nicht verfügbar.", + "app_survey": "App-Umfrage", "card_description": "Beschreibe, was du herausfinden möchtest, und erstelle einen Umfrage-Entwurf.", "card_title": "Mit KI erstellen", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "In den Einstellungen aktivieren", "generated_payload_invalid": "Der Umfrageentwurf konnte nicht validiert werden. Versuche, mehr Details hinzuzufügen.", "link_survey": "Link-Umfrage", - "only_link_supported": "Momentan kann nur Link-Umfrage erstellt werden.", "opening_editor": "Editor wird geöffnet...", "prompt_helper_churn": "Verstehe, warum aktive Kunden abwandern könnten und was sie bei der Stange halten würde", "prompt_helper_churn_label": "Abwanderungsrisiko", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "z. B. Verstehen, warum neue Nutzer während des Onboardings abbrechen und was ihnen helfen würde, die Einrichtung abzuschließen", "shortcut_hint": "⌘/Strg + Enter zum Erstellen", "start_from_scratch": "Von vorne beginnen", + "survey_type_help": "Du kannst die Einrichtung nach dem Erstellen des Entwurfs im Editor abschließen.", "survey_type_label": "Umfragetyp", "try_prompt": "Beispiel-Prompt ansehen", "upgrade_plan": "Tarif upgraden" diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index dee7751233a9..9e55952a865f 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -2682,6 +2682,7 @@ "ai_not_available": "AI survey creation is not available.", "ai_not_enabled": "AI smart tools are disabled for this organization.", "ai_not_in_plan": "AI survey creation is not available on your current plan.", + "app_survey": "App Survey", "card_description": "Describe what you want to learn and create a draft survey.", "card_title": "Create with AI", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Enable in settings", "generated_payload_invalid": "The survey draft could not be validated. Try adding more detail.", "link_survey": "Link Survey", - "only_link_supported": "Only Link surveys are currently supported.", "opening_editor": "Opening editor...", "prompt_helper_churn": "Understand why active customers may churn and what would keep them engaged", "prompt_helper_churn_label": "Churn risk", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "e.g. Understand why new users stop during onboarding and what would help them finish setup", "shortcut_hint": "⌘/Ctrl + Enter to create", "start_from_scratch": "Start from scratch", + "survey_type_help": "You can finish setup in the editor after the draft is created.", "survey_type_label": "Survey type", "try_prompt": "See example prompt", "upgrade_plan": "Upgrade plan" diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index b407c32b930d..1c00196d6f46 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -2682,6 +2682,7 @@ "ai_not_available": "La creación de encuestas con IA no está disponible.", "ai_not_enabled": "Las herramientas inteligentes de IA están desactivadas para esta organización.", "ai_not_in_plan": "La creación de encuestas con IA no está disponible en tu plan actual.", + "app_survey": "Encuesta de aplicación", "card_description": "Describe lo que quieres aprender y crea un borrador de encuesta.", "card_title": "Crear con IA", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Activar en ajustes", "generated_payload_invalid": "El borrador de la encuesta no pudo validarse. Intenta añadir más detalles.", "link_survey": "Encuesta por Enlace", - "only_link_supported": "Solo se puede crear Encuesta por Enlace por ahora.", "opening_editor": "Abriendo editor...", "prompt_helper_churn": "Entiende por qué los clientes activos podrían abandonar y qué les mantendría comprometidos", "prompt_helper_churn_label": "Riesgo de abandono", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "p. ej. Entender por qué los nuevos usuarios abandonan durante el onboarding y qué les ayudaría a completar la configuración", "shortcut_hint": "⌘/Ctrl + Enter para crear", "start_from_scratch": "Empezar desde cero", + "survey_type_help": "Puedes terminar la configuración en el editor después de crear el borrador.", "survey_type_label": "Tipo de encuesta", "try_prompt": "Ver ejemplo de instrucción", "upgrade_plan": "Mejorar plan" diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index b33f1c830857..0ab2a5783198 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -2682,6 +2682,7 @@ "ai_not_available": "La création de questionnaires par IA n'est pas disponible.", "ai_not_enabled": "Les outils intelligents IA sont désactivés pour cette organisation.", "ai_not_in_plan": "La création de questionnaires par IA n'est pas disponible avec ton forfait actuel.", + "app_survey": "Sondage d'application", "card_description": "Décris ce que tu veux apprendre et crée un brouillon de questionnaire.", "card_title": "Créer avec l'IA", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Activer dans les paramètres", "generated_payload_invalid": "Le brouillon du questionnaire n'a pas pu être validé. Essaie d'ajouter plus de détails.", "link_survey": "Questionnaire par lien", - "only_link_supported": "Seuls les questionnaires par lien peuvent être créés pour le moment.", "opening_editor": "Ouverture de l'éditeur...", "prompt_helper_churn": "Comprendre pourquoi les clients actifs pourraient partir et ce qui les maintiendrait engagés", "prompt_helper_churn_label": "Risque d'attrition", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "ex. Comprendre pourquoi les nouveaux utilisateurs abandonnent pendant l'onboarding et ce qui les aiderait à terminer la configuration", "shortcut_hint": "⌘/Ctrl + Entrée pour créer", "start_from_scratch": "Partir de zéro", + "survey_type_help": "Tu peux terminer la configuration dans l'éditeur après la création du brouillon.", "survey_type_label": "Type de sondage", "try_prompt": "Voir un exemple d'instruction", "upgrade_plan": "Mettre à niveau l'abonnement" diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 752895e57946..81dd657d9625 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -2682,6 +2682,7 @@ "ai_not_available": "Az AI kérdőív-készítés nem elérhető.", "ai_not_enabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.", "ai_not_in_plan": "Az AI kérdőív-készítés nem elérhető az Ön jelenlegi csomagjában.", + "app_survey": "Alkalmazás-kérdőív", "card_description": "Írja le, hogy mit szeretne megtudni, és hozzon létre egy kérdőívtervezetet.", "card_title": "Készítsen AI-val", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Engedélyezze a beállításokban", "generated_payload_invalid": "A kérdőívvázlatot nem sikerült érvényesíteni. Próbálj meg több részletet hozzáadni.", "link_survey": "Linkes kérdőív", - "only_link_supported": "Jelenleg csak linkes kérdőív hozható létre.", "opening_editor": "Szerkesztő megnyitása...", "prompt_helper_churn": "Értse meg, hogy az aktív ügyfelek miért hagyhatják el a szolgáltatást, és mi tartaná meg őket elkötelezetten", "prompt_helper_churn_label": "Lemorzsolódási kockázat", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "pl. Megérteni, miért hagyják abba az új felhasználók a regisztrációt, és mi segítené őket a beállítás befejezésében", "shortcut_hint": "⌘/Ctrl + Enter a létrehozáshoz", "start_from_scratch": "Kezdés a nulláról", + "survey_type_help": "A beállítást a szerkesztőben fejezheted be, miután a piszkozat elkészült.", "survey_type_label": "Felmérés típusa", "try_prompt": "Példa promptra", "upgrade_plan": "Csomag frissítése" diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 16324c85e652..19b62adaad5d 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -2682,6 +2682,7 @@ "ai_not_available": "AIによるアンケート作成は利用できません。", "ai_not_enabled": "この組織ではAIスマートツールが無効になっています。", "ai_not_in_plan": "現在のプランではAIによるアンケート作成は利用できません。", + "app_survey": "アプリ内フォーム", "card_description": "知りたいことを説明するだけで、アンケートの下書きを作成できます。", "card_title": "AIで作成", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "設定で有効にする", "generated_payload_invalid": "アンケートの下書きを検証できませんでした。詳細を追加してみてください。", "link_survey": "リンク型アンケート", - "only_link_supported": "現在、リンク型アンケートのみ作成できます。", "opening_editor": "エディターを開いています...", "prompt_helper_churn": "アクティブな顧客が離脱する理由と、顧客のエンゲージメントを維持するために必要なことを理解する", "prompt_helper_churn_label": "解約リスク", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "例:新規ユーザーがオンボーディング中に離脱する理由と、セットアップ完了に必要なサポートを理解する", "shortcut_hint": "⌘/Ctrl + Enterで作成", "start_from_scratch": "ゼロから作成", + "survey_type_help": "下書きの作成後、エディターでセットアップを完了できます。", "survey_type_label": "アンケートタイプ", "try_prompt": "プロンプト例を見る", "upgrade_plan": "プランをアップグレード" diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 19b6497626ca..ac8d74c31d72 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -2682,6 +2682,7 @@ "ai_not_available": "AI-enquêtecreatie is niet beschikbaar.", "ai_not_enabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.", "ai_not_in_plan": "AI-enquêtecreatie is niet beschikbaar in je huidige abonnement.", + "app_survey": "App-enquête", "card_description": "Beschrijf wat je wilt leren en maak een conceptenquête.", "card_title": "Maak met AI", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Inschakelen in instellingen", "generated_payload_invalid": "Het enquêteconcept kon niet worden gevalideerd. Probeer meer details toe te voegen.", "link_survey": "Link-enquête", - "only_link_supported": "Alleen Link-enquête kan op dit moment worden aangemaakt.", "opening_editor": "Editor openen...", "prompt_helper_churn": "Begrijp waarom actieve klanten mogelijk afhaken en wat hen betrokken zou houden", "prompt_helper_churn_label": "Klantverloop risico", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "bijv. Begrijp waarom nieuwe gebruikers afhaken tijdens onboarding en wat hen zou helpen om de setup af te ronden", "shortcut_hint": "⌘/Ctrl + Enter om aan te maken", "start_from_scratch": "Begin vanaf nul", + "survey_type_help": "Je kunt de installatie in de editor voltooien nadat het concept is aangemaakt.", "survey_type_label": "Type enquête", "try_prompt": "Bekijk voorbeeldprompt", "upgrade_plan": "Upgrade abonnement" diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index e6e204d34860..d85f93dcd1ec 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -2682,6 +2682,7 @@ "ai_not_available": "A criação de pesquisas com IA não está disponível.", "ai_not_enabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.", "ai_not_in_plan": "A criação de pesquisas com IA não está disponível no seu plano atual.", + "app_survey": "Pesquisa de App", "card_description": "Descreva o que você quer aprender e crie um rascunho de pesquisa.", "card_title": "Criar com IA", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Habilitar nas configurações", "generated_payload_invalid": "O rascunho da pesquisa não pôde ser validado. Tente adicionar mais detalhes.", "link_survey": "Pesquisa de Link", - "only_link_supported": "Apenas a Pesquisa de Link pode ser criada no momento.", "opening_editor": "Abrindo o editor...", "prompt_helper_churn": "Entenda por que clientes ativos podem cancelar e o que os manteria engajados", "prompt_helper_churn_label": "Risco de cancelamento", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "ex: Entender por que novos usuários abandonam durante a integração e o que os ajudaria a concluir a configuração", "shortcut_hint": "⌘/Ctrl + Enter para criar", "start_from_scratch": "Começar do zero", + "survey_type_help": "Você pode concluir a configuração no editor após criar o rascunho.", "survey_type_label": "Tipo de pesquisa", "try_prompt": "Ver exemplo de prompt", "upgrade_plan": "Fazer upgrade do plano" diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index e26c613902c7..1e5ee72bb8b0 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -2682,6 +2682,7 @@ "ai_not_available": "A criação de inquéritos com IA não está disponível.", "ai_not_enabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.", "ai_not_in_plan": "A criação de inquéritos com IA não está disponível no teu plano atual.", + "app_survey": "Inquérito (app)", "card_description": "Descreve o que queres aprender e cria um rascunho de inquérito.", "card_title": "Criar com IA", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Ativar nas definições", "generated_payload_invalid": "O rascunho do inquérito não pôde ser validado. Tenta adicionar mais detalhes.", "link_survey": "Inquérito de Link", - "only_link_supported": "Apenas o Inquérito de Link pode ser criado neste momento.", "opening_editor": "A abrir o editor...", "prompt_helper_churn": "Compreende porque é que os clientes ativos podem abandonar e o que os manteria envolvidos", "prompt_helper_churn_label": "Risco de abandono", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "ex: Perceber porque é que os novos utilizadores desistem durante o onboarding e o que os ajudaria a concluir a configuração", "shortcut_hint": "⌘/Ctrl + Enter para criar", "start_from_scratch": "Começar do zero", + "survey_type_help": "Podes concluir a configuração no editor depois de criar o rascunho.", "survey_type_label": "Tipo de inquérito", "try_prompt": "Ver exemplo de prompt", "upgrade_plan": "Melhorar plano" diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index a055b703467b..310328b3fa69 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -2682,6 +2682,7 @@ "ai_not_available": "Crearea de chestionare cu AI nu este disponibilă.", "ai_not_enabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.", "ai_not_in_plan": "Crearea de chestionare cu AI nu este disponibilă în planul tău actual.", + "app_survey": "Sondaj aplicație", "card_description": "Descrie ce vrei să afli și creează o ciornă de chestionar.", "card_title": "Creează cu AI", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Activează în setări", "generated_payload_invalid": "Schița chestionarului nu a putut fi validată. Încearcă să adaugi mai multe detalii.", "link_survey": "Chestionar prin Link", - "only_link_supported": "Doar Chestionarul prin Link poate fi creat momentan.", "opening_editor": "Se deschide editorul...", "prompt_helper_churn": "Înțelege de ce clienții activi ar putea pleca și ce i-ar menține implicați", "prompt_helper_churn_label": "Risc de abandon", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "ex. Înțelege de ce utilizatorii noi renunță în timpul procesului de onboarding și ce i-ar ajuta să finalizeze configurarea", "shortcut_hint": "⌘/Ctrl + Enter pentru a crea", "start_from_scratch": "Începe de la zero", + "survey_type_help": "Puteți finaliza configurarea în editor după ce este creată ciorna.", "survey_type_label": "Tip de sondaj", "try_prompt": "Vezi exemplu de solicitare", "upgrade_plan": "Actualizează planul" diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 2e5c58ac2dbd..b349ce22a128 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -2682,6 +2682,7 @@ "ai_not_available": "Создание опросов с помощью ИИ недоступно.", "ai_not_enabled": "Умные инструменты ИИ отключены для этой организации.", "ai_not_in_plan": "Создание опросов с помощью ИИ недоступно в твоём текущем тарифе.", + "app_survey": "Опрос о приложении", "card_description": "Опиши, что ты хочешь узнать, и создай черновик опроса.", "card_title": "Создать с помощью ИИ", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Включи в настройках", "generated_payload_invalid": "Черновик опроса не удалось проверить. Попробуй добавить больше деталей.", "link_survey": "Опрос по ссылке", - "only_link_supported": "Сейчас можно создать только опрос по ссылке.", "opening_editor": "Открываем редактор...", "prompt_helper_churn": "Узнай, почему активные клиенты могут уйти и что удержит их вовлечёнными", "prompt_helper_churn_label": "Риск оттока", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "например, Понять, почему новые пользователи прекращают онбординг и что помогло бы им завершить настройку", "shortcut_hint": "⌘/Ctrl + Enter для создания", "start_from_scratch": "Начать с нуля", + "survey_type_help": "Вы можете завершить настройку в редакторе после создания черновика.", "survey_type_label": "Тип опроса", "try_prompt": "Посмотреть пример запроса", "upgrade_plan": "Обновить план" diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 7b37534e2d5a..60f9a87d764e 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -2682,6 +2682,7 @@ "ai_not_available": "AI-enkätskapande är inte tillgängligt.", "ai_not_enabled": "AI-smarta verktyg är inaktiverade för den här organisationen.", "ai_not_in_plan": "AI-enkätskapande är inte tillgängligt i din nuvarande plan.", + "app_survey": "App-enkät", "card_description": "Beskriv vad du vill lära dig och skapa ett utkast till enkät.", "card_title": "Skapa med AI", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Aktivera i inställningar", "generated_payload_invalid": "Enkätutkastet kunde inte valideras. Försök lägga till mer detaljer.", "link_survey": "Länk-enkät", - "only_link_supported": "Endast Länk-enkät kan skapas just nu.", "opening_editor": "Öppnar redigeraren...", "prompt_helper_churn": "Förstå varför aktiva kunder kan försvinna och vad som skulle hålla dem engagerade", "prompt_helper_churn_label": "Churnrisk", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "t.ex. Förstå varför nya användare avbryter under onboarding och vad som skulle hjälpa dem att slutföra installationen", "shortcut_hint": "⌘/Ctrl + Enter för att skapa", "start_from_scratch": "Börja från början", + "survey_type_help": "Du kan slutföra konfigurationen i redigeraren efter att utkastet har skapats.", "survey_type_label": "Undersökningstyp", "try_prompt": "Se exempelprompt", "upgrade_plan": "Uppgradera abonnemang" diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index f3ede9c134f4..491790c490b6 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -2682,6 +2682,7 @@ "ai_not_available": "AI ile anket oluşturma özelliği kullanılamıyor.", "ai_not_enabled": "Bu organizasyon için AI akıllı araçlar devre dışı.", "ai_not_in_plan": "AI ile anket oluşturma mevcut planında mevcut değil.", + "app_survey": "Uygulama Anketi", "card_description": "Ne öğrenmek istediğini anlat ve bir taslak anket oluştur.", "card_title": "AI ile Oluştur", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "Ayarlardan etkinleştir", "generated_payload_invalid": "Anket taslağı doğrulanamadı. Daha fazla detay eklemeyi dene.", "link_survey": "Link Anketi", - "only_link_supported": "Şu anda sadece Link Anketi oluşturulabiliyor.", "opening_editor": "Düzenleyici açılıyor...", "prompt_helper_churn": "Aktif müşterilerin neden kaybedilebileceğini ve onları bağlı tutacak şeylerin neler olduğunu anla", "prompt_helper_churn_label": "Kayıp riski", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "örn. Yeni kullanıcıların onboarding sırasında neden ayrıldığını ve kurulumu tamamlamalarına neyin yardımcı olacağını anlamak", "shortcut_hint": "Oluşturmak için ⌘/Ctrl + Enter", "start_from_scratch": "Sıfırdan başla", + "survey_type_help": "Taslak oluşturulduktan sonra kurulumu düzenleyicide tamamlayabilirsiniz.", "survey_type_label": "Anket türü", "try_prompt": "Örnek komutu gör", "upgrade_plan": "Planı yükselt" diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 8990e4150ebb..3156f3b6e426 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -2682,6 +2682,7 @@ "ai_not_available": "AI 问卷创建功能不可用。", "ai_not_enabled": "此组织已禁用 AI 智能工具。", "ai_not_in_plan": "你当前的套餐不支持 AI 问卷创建功能。", + "app_survey": "应用 程序 调查", "card_description": "描述你想了解的内容,创建问卷草稿。", "card_title": "使用 AI 创建", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "在设置中启用", "generated_payload_invalid": "问卷草稿无法验证。请尝试添加更多详细信息。", "link_survey": "链接问卷", - "only_link_supported": "目前仅支持创建链接问卷。", "opening_editor": "正在打开编辑器...", "prompt_helper_churn": "了解活跃客户可能流失的原因以及什么能让他们保持参与", "prompt_helper_churn_label": "流失风险", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "例如:了解新用户为什么在引导过程中流失,以及哪些帮助能让他们完成设置", "shortcut_hint": "⌘/Ctrl + Enter 创建", "start_from_scratch": "从头开始", + "survey_type_help": "创建草稿后,您可以在编辑器中完成设置。", "survey_type_label": "调查类型", "try_prompt": "查看示例提示", "upgrade_plan": "升级套餐" diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 5f301c67bf3f..db6365e1d0d8 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -2682,6 +2682,7 @@ "ai_not_available": "AI 問卷建立功能目前無法使用。", "ai_not_enabled": "此組織已停用 AI 智慧工具。", "ai_not_in_plan": "你目前的方案不包含 AI 問卷建立功能。", + "app_survey": "應用程式問卷", "card_description": "描述你想了解的內容,即可建立問卷草稿。", "card_title": "使用 AI 建立", "characters": "{count}/{max}", @@ -2694,7 +2695,6 @@ "enable_ai_in_settings": "在設定中啟用", "generated_payload_invalid": "問卷草稿無法驗證。請嘗試新增更多細節。", "link_survey": "連結問卷", - "only_link_supported": "目前只能建立連結問卷。", "opening_editor": "正在開啟編輯器...", "prompt_helper_churn": "了解活躍客戶可能流失的原因,以及如何讓他們保持參與度", "prompt_helper_churn_label": "流失風險", @@ -2708,6 +2708,7 @@ "prompt_placeholder": "例如:了解新用戶為何在引導過程中停止,以及什麼能幫助他們完成設定", "shortcut_hint": "⌘/Ctrl + Enter 建立", "start_from_scratch": "從零開始", + "survey_type_help": "建立草稿後,您可以在編輯器中完成設定。", "survey_type_label": "問卷類型", "try_prompt": "查看範例提示", "upgrade_plan": "升級方案" diff --git a/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx b/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx index 62c4d5485f2d..79e54bdc7bf3 100644 --- a/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx +++ b/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx @@ -144,12 +144,14 @@ export const CreateWithAIForm = ({ {SURVEY_TYPE_OPTIONS.map((option) => ( - {t("workspace.surveys.ai_create.link_survey")} + {option.value === "app" + ? t("workspace.surveys.ai_create.app_survey") + : t("workspace.surveys.ai_create.link_survey")} ))} -

{t("workspace.surveys.ai_create.only_link_supported")}

+

{t("workspace.surveys.ai_create.survey_type_help")}

)} diff --git a/apps/web/modules/survey/components/template-list/lib/ai-create-utils.test.ts b/apps/web/modules/survey/components/template-list/lib/ai-create-utils.test.ts index 3fcda7a7a224..a32591f9a035 100644 --- a/apps/web/modules/survey/components/template-list/lib/ai-create-utils.test.ts +++ b/apps/web/modules/survey/components/template-list/lib/ai-create-utils.test.ts @@ -12,7 +12,7 @@ describe("ai-create-utils", () => { test("defines prompt limits and supported survey types", () => { expect(AI_SURVEY_PROMPT_MIN_LENGTH).toBe(4); expect(AI_SURVEY_PROMPT_MAX_LENGTH).toBe(1200); - expect(SURVEY_TYPE_OPTIONS).toEqual([{ value: "link" }]); + expect(SURVEY_TYPE_OPTIONS).toEqual([{ value: "link" }, { value: "app" }]); }); test("returns the unavailable message key for each AI reason", () => { diff --git a/apps/web/modules/survey/components/template-list/lib/ai-create-utils.ts b/apps/web/modules/survey/components/template-list/lib/ai-create-utils.ts index 47cf4e3f398e..5d76aedc2582 100644 --- a/apps/web/modules/survey/components/template-list/lib/ai-create-utils.ts +++ b/apps/web/modules/survey/components/template-list/lib/ai-create-utils.ts @@ -11,9 +11,9 @@ import type { TAIUnavailableReason } from "@/lib/ai/service"; export const AI_SURVEY_PROMPT_MIN_LENGTH = 4; export const AI_SURVEY_PROMPT_MAX_LENGTH = 1200; -export type TSurveyGenerationType = "link"; +export type TSurveyGenerationType = "link" | "app"; -export const SURVEY_TYPE_OPTIONS: { value: TSurveyGenerationType }[] = [{ value: "link" }]; +export const SURVEY_TYPE_OPTIONS: { value: TSurveyGenerationType }[] = [{ value: "link" }, { value: "app" }]; export const getUnavailableMessageKey = (reason?: TAIUnavailableReason) => { if (reason === "read_only") { diff --git a/docs/api-v3-reference/openapi.yml b/docs/api-v3-reference/openapi.yml index 5c6db05ca3e2..69669239b1f0 100644 --- a/docs/api-v3-reference/openapi.yml +++ b/docs/api-v3-reference/openapi.yml @@ -12,7 +12,7 @@ info: **Spec location:** source of truth is the multi-file tree at `docs/api-v3-reference/src/` (root `openapi.yml` plus one file per path and component); `docs/api-v3-reference/openapi.yml` is the generated single-file bundle (alongside v2 at `docs/api-v2-reference/openapi.yml`). **workspaceId** - Query param `workspaceId` is the canonical container identifier for this API. + `workspaceId` is the canonical container identifier for this API. It is a **required query parameter on collection read endpoints** (`GET /api/v3/surveys`, `GET /api/v3/action-classes`, `GET /api/v3/contact-attribute-keys`) because those operate within a workspace. **`POST /api/v3/surveys` instead requires `workspaceId` in the request body**, not as a query parameter. Single-resource endpoints (`GET`/`PATCH`/`DELETE /api/v3/surveys/{surveyId}`) locate the resource by its globally-unique id and resolve the workspace from it, so they do **not** accept a `workspaceId` query parameter — sending one returns **400** (`Unrecognized key: "workspaceId"`). **Auth** Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation. @@ -27,7 +27,7 @@ info: Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`. **AI survey creation** - `POST /api/v3/surveys/generate` returns a draft `POST /api/v3/surveys` payload plus validation metadata. It does not create a survey. Generated payloads are currently link-survey only because the v3 create contract accepts only `type: link`. + `POST /api/v3/surveys/generate` returns a draft `POST /api/v3/surveys` payload plus validation metadata. It does not create a survey. It generates survey content for both `link` and `app` surveys; for `app` it seeds a default `distribution` (display once, no triggers, no targeting) that you finish configuring before publishing. Prompt text is sent to the configured AI provider for generation, but it is not stored by this endpoint, not logged by default, and not included in audit data. **OpenAPI** @@ -179,40 +179,15 @@ paths: minimum: 0 description: Total number of surveys matching the current filters across all pages. `null` when `includeTotalCount=false`. '400': - description: Bad Request - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3BadRequest' '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Unauthorized' '403': - description: Forbidden — no access, or workspace does not exist (404 not used; avoids existence leak) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Forbidden' '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3TooManyRequests' '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] @@ -235,9 +210,7 @@ paths: when the same block has at least one `logic` rule; otherwise the API returns `invalid_reference`. - This first write surface intentionally stays structure-focused: `type` may be omitted or set - to `link` or `app`, but in-app survey creation and distribution-channel settings are not part - of this operation. Unsupported fields are rejected instead of ignored. + `type` may be omitted or set to `link` or `app`. App surveys additionally accept the `distribution` (display options + triggers) and `targeting` (contact segment filters) objects; both are rejected for link surveys. Trigger ids must reference existing workspace action classes (discover them via `GET /api/v3/action-classes`), and `targeting.filters` references (contact-attribute keys and segments) must exist in the workspace. Unsupported fields are rejected instead of ignored. tags: - V3 Surveys requestBody: @@ -280,6 +253,69 @@ paths: hiddenFields: enabled: false variables: [] + appSurvey: + summary: Create an in-app (app) survey with distribution and triggers + description: | + App surveys are shown inside your web or mobile app. `distribution` controls display behavior and the action classes that trigger the survey (by id — discover ids via `GET /api/v3/action-classes`). `targeting.filters` scopes the audience; an empty array targets everyone. `type` is immutable after creation. + value: + workspaceId: clxx1234567890123456789012 + name: In-App Onboarding Feedback + type: app + status: draft + defaultLanguage: en-US + blocks: + - name: Onboarding + elements: + - id: onboarding_feedback + type: openText + headline: + en-US: How was your setup experience? + required: false + endings: [] + hiddenFields: + enabled: false + variables: [] + distribution: + displayOption: respondMultiple + recontactDays: 7 + delay: 5 + triggers: + - actionClassId: clyy1234567890123456789012 + targeting: + filters: + - id: clf0aaaaaaaaaaaaaaaaaaaa01 + connector: null + resource: + id: clf1aaaaaaaaaaaaaaaaaaaa01 + root: + type: attribute + contactAttributeKey: plan + qualifier: + operator: equals + value: pro + - id: clf2aaaaaaaaaaaaaaaaaaaa01 + connector: and + resource: + - id: clf3aaaaaaaaaaaaaaaaaaaa01 + connector: null + resource: + id: clf4aaaaaaaaaaaaaaaaaaaa01 + root: + type: attribute + contactAttributeKey: role + qualifier: + operator: equals + value: admin + - id: clf5aaaaaaaaaaaaaaaaaaaa01 + connector: or + resource: + id: clf6aaaaaaaaaaaaaaaaaaaa01 + root: + type: attribute + contactAttributeKey: role + qualifier: + operator: equals + value: owner sequentialProductSurvey: summary: Create a richer sequential survey without logicFallback description: | @@ -380,41 +416,29 @@ paths: $ref: '#/components/schemas/SurveyResource' '400': description: | - Bad Request — invalid JSON, unsupported fields, malformed multilingual maps, duplicate - stable ids, or dangling logic/reference ids. + 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`. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Unauthorized' '403': - description: Forbidden — no write access, missing external URL permission, or workspace does not exist (404 not used; avoids existence leak) + description: Forbidden — no write access, missing external URL permission, missing Contacts entitlement for app-survey targeting, or workspace does not exist (404 not used; avoids existence leak) content: application/problem+json: schema: $ref: '#/components/schemas/Problem' - '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets + '422': + description: 'Unprocessable Content — the document passed schema validation but failed a cross-reference check that requires stored state to detect: duplicate stable ids, dangling logic/jump references, undeclared locale keys used in content, invalid media URLs, a `distribution.triggers[].actionClassId` referencing an action class that does not exist in the workspace (discover valid ids via `GET /api/v3/action-classes`), or a `targeting.filters` entry that cannot resolve in the workspace — an unknown contact-attribute key or segment, an unsupported person identifier, or an unknown device value. The `invalid_params` array pinpoints each issue (e.g. `code: invalid_reference` with the offending `identifier`).' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' + '429': + $ref: '#/components/responses/V3TooManyRequests' '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] @@ -428,9 +452,9 @@ paths: Clients should validate the returned payload with `/api/v3/surveys/validate` or use the embedded validation metadata, then create the draft through `POST /api/v3/surveys`. - The generated payload always sets `status: draft`. The initial contract supports only - `type: link`; app and website survey creation must stay disabled until the v3 create - endpoint accepts those types. + The generated payload always sets `status: draft`. Both `type: link` and `type: app` are + supported; for `app`, the payload includes a default `distribution` (display once, no triggers, + no targeting) that you finish configuring in the editor or via the create request. Prompt privacy: prompt text is sent to the configured AI provider to create the payload, but this endpoint does not store prompts and server logs should use request ids and failure @@ -541,11 +565,7 @@ paths: - name: prompt reason: Describe the survey goal, audience, or topic in a sentence so the AI can create a useful draft. '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Unauthorized' '403': description: Forbidden — no write access, workspace does not exist, AI is not in plan, or AI smart tools are disabled content: @@ -569,6 +589,14 @@ paths: detail: AI smart tools are disabled for this organization. code: ai_smart_tools_disabled requestId: req_123 + aiNotInPlan: + summary: AI features not enabled for the plan + value: + title: AI Unavailable + status: 403 + detail: AI features are not enabled for this organization's plan. + code: ai_features_not_enabled + requestId: req_123 '422': description: AI generated an invalid payload after schema and v3 create validation content: @@ -588,22 +616,9 @@ paths: - name: generatedSurvey.blocks reason: Too small '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3TooManyRequests' '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3InternalServerError' '502': description: The configured AI provider failed or could not produce a valid draft content: @@ -739,34 +754,13 @@ paths: schema: $ref: '#/components/schemas/Problem' '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Unauthorized' '403': - description: Forbidden — no write access, or survey/workspace does not exist - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Forbidden' '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3TooManyRequests' '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] @@ -921,34 +915,13 @@ paths: schema: $ref: '#/components/schemas/Problem' '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Unauthorized' '403': - description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Forbidden' '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3TooManyRequests' '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] @@ -1039,42 +1012,29 @@ paths: $ref: '#/components/schemas/SurveyResource' '400': description: | - Bad Request — malformed JSON, unsupported query parameter, unsupported field, - invalid locale map, duplicate id, immutable element id change, dangling reference, - or invalid survey document. + 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`. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Unauthorized' '403': - description: Forbidden — no write access, or survey does not exist (404 not used; avoids existence leak) + description: Forbidden — no write access, missing external URL permission, missing Contacts entitlement for app-survey targeting, or survey does not exist (404 not used; avoids existence leak) content: application/problem+json: schema: $ref: '#/components/schemas/Problem' - '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets + '422': + description: 'Unprocessable Content — the patched document passed schema validation but failed a cross-reference check that requires stored state to detect: an undeclared locale key used in content, a duplicate stable id, an immutable element-id change on a published survey, a dangling logic/jump reference, an invalid media URL, a `distribution.triggers[].actionClassId` referencing an action class that does not exist in the workspace (discover valid ids via `GET /api/v3/action-classes`), or a `targeting.filters` entry that cannot resolve in the workspace — an unknown contact-attribute key or segment, an unsupported person identifier, or an unknown device value. The `invalid_params` array pinpoints each issue (e.g. `code: invalid_reference` with the offending `identifier`).' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' + '429': + $ref: '#/components/responses/V3TooManyRequests' '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] @@ -1105,40 +1065,188 @@ paths: type: string example: private, no-store '400': - description: Bad Request - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3BadRequest' '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Unauthorized' '403': - description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak) - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/responses/V3Forbidden' '429': - description: Rate limit exceeded + $ref: '#/components/responses/V3TooManyRequests' + '500': + $ref: '#/components/responses/V3InternalServerError' + security: + - sessionAuth: [] + - apiKeyAuth: [] + /api/v3/action-classes: + get: + operationId: listActionClassesV3 + summary: List action classes + description: | + Returns the action classes (user actions that can trigger an app survey) for a workspace. Use + the returned ids as `distribution.triggers[].actionClassId` when creating or updating app + surveys. Read-only: this v3 endpoint lists action classes but does not create them — action + classes are managed in the Formbricks app (Actions settings) or via the v1/v2 management API. + + An empty `data` array means the workspace has no action classes yet. Triggers are optional, so + you can still create an app survey with `distribution.triggers: []` (or omit `distribution` + entirely) and attach triggers later via `PATCH` once an action class exists. Session cookie or + x-api-key. + tags: + - V3 Action Classes + parameters: + - in: query + name: workspaceId + required: true + schema: + type: string + format: cuid2 + description: | + Workspace identifier. This is the canonical container ID for v3 APIs. + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + description: Page size (max 100). + - in: query + name: cursor + schema: + type: string + description: | + Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request. + responses: + '200': + description: Action classes retrieved successfully headers: - Retry-After: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: schema: - type: integer - description: Seconds until the current rate-limit window resets + type: string + example: private, no-store content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/Problem' + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/ActionClassResource' + meta: + type: object + required: + - limit + - nextCursor + properties: + limit: + type: integer + nextCursor: + type: + - string + - 'null' + description: Opaque cursor for the next page. `null` when there are no more results. + '400': + $ref: '#/components/responses/V3BadRequest' + '401': + $ref: '#/components/responses/V3Unauthorized' + '403': + $ref: '#/components/responses/V3Forbidden' + '429': + $ref: '#/components/responses/V3TooManyRequests' '500': - description: Internal Server Error + $ref: '#/components/responses/V3InternalServerError' + security: + - sessionAuth: [] + - apiKeyAuth: [] + /api/v3/contact-attribute-keys: + get: + operationId: listContactAttributeKeysV3 + summary: List contact attribute keys + description: | + Returns the contact attribute keys for a workspace. Use the returned `key` values as + `targeting.filters[].root.contactAttributeKey` when targeting app surveys; `dataType` hints which + operators apply. Session cookie or x-api-key. (Applying targeting filters requires the Contacts + entitlement; this discovery endpoint does not.) + tags: + - V3 Contact Attribute Keys + parameters: + - in: query + name: workspaceId + required: true + schema: + type: string + format: cuid2 + description: | + Workspace identifier. This is the canonical container ID for v3 APIs. + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + description: Page size (max 100). + - in: query + name: cursor + schema: + type: string + description: | + Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request. + responses: + '200': + description: Contact attribute keys retrieved successfully + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/Problem' + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/ContactAttributeKeyResource' + meta: + type: object + required: + - limit + - nextCursor + properties: + limit: + type: integer + nextCursor: + type: + - string + - 'null' + description: Opaque cursor for the next page. `null` when there are no more results. + '400': + $ref: '#/components/responses/V3BadRequest' + '401': + $ref: '#/components/responses/V3Unauthorized' + '403': + $ref: '#/components/responses/V3Forbidden' + '429': + $ref: '#/components/responses/V3TooManyRequests' + '500': + $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] @@ -1164,6 +1272,17 @@ components: Shape returned by `GET /api/v3/surveys`. Serialized dates are ISO 8601 strings. The v3 overview contract intentionally omits internal fields such as `_count`. Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`. + required: + - id + - name + - workspaceId + - type + - status + - publishOn + - createdAt + - updatedAt + - responseCount + - creator properties: id: type: string @@ -1191,24 +1310,25 @@ components: updatedAt: type: string format: date-time + publishOn: + type: + - string + - 'null' + format: date-time + description: Scheduled publish time (ISO 8601), or null if not scheduled. responseCount: type: integer creator: type: - object - 'null' + description: The user who created the survey, or null for API-key/system-created surveys. + required: + - name properties: name: type: string - singleUse: - type: - - object - - 'null' - properties: - enabled: - type: boolean - isEncrypted: - type: boolean + additionalProperties: false InvalidParam: type: object description: | @@ -1297,6 +1417,8 @@ components: - forbidden - internal_server_error - not_authenticated + - not_found + - payload_too_large - too_many_requests - unprocessable_content requestId: @@ -2423,14 +2545,354 @@ components: Survey variable accepted by `POST /api/v3/surveys`. `id` may be omitted and will be generated by the server. Provide an explicit cuid2 id when logic in the same request needs to reference this variable. + SurveyTrigger: + type: object + description: | + Reference to an existing workspace action class that triggers this app survey. Discover available action-class ids with `GET /api/v3/action-classes`. + required: + - actionClassId + properties: + actionClassId: + type: string + format: cuid2 + description: Id of an action class in the same workspace. + additionalProperties: false + SurveyDistribution: + type: object + description: | + App-survey runtime/display settings. Only valid for `type: app`; rejected for link surveys. + **Replacement semantics (destructive):** when `distribution` is provided on PATCH it fully replaces the stored object — any sub-field you omit is reset to its default, NOT preserved. In particular, omitting `triggers` removes ALL existing triggers, and omitting `displayOption` resets it to `displayOnce`. To change one setting, send the complete desired distribution. To leave distribution untouched, omit the whole `distribution` key. + properties: + displayOption: + type: string + enum: + - displayOnce + - displayMultiple + - respondMultiple + - displaySome + default: displayOnce + description: | + How often the survey may be shown to a contact. `displaySome` shows it to `displayPercentage` of contacts. + displayPercentage: + type: + - number + - 'null' + minimum: 0.01 + maximum: 100 + default: null + description: | + Percentage of contacts to show the survey to. Required when `displayOption` is `displaySome`; must be null/omitted otherwise. + displayLimit: + type: + - integer + - 'null' + minimum: 0 + default: null + description: Maximum number of times the survey is shown to a single contact. + recontactDays: + type: + - integer + - 'null' + minimum: 0 + default: null + description: Days to wait before showing this survey again to the same contact. Null uses the workspace default. + autoClose: + type: + - integer + - 'null' + minimum: 0 + default: null + description: Seconds of inactivity after which the survey widget auto-closes. Null disables auto-close. + autoComplete: + type: + - integer + - 'null' + minimum: 1 + default: null + description: Automatically stop collecting responses after this many completed responses. Null disables it. + delay: + type: integer + minimum: 0 + default: 0 + description: Seconds to wait after a trigger fires before showing the survey. + triggers: + type: array + default: [] + items: + $ref: '#/components/schemas/SurveyTrigger' + description: | + Action classes that trigger the survey. Every id must reference an existing workspace action class. On PATCH this list fully replaces the survey's triggers — omitting it (or sending `[]`) removes all existing triggers. + additionalProperties: false + example: + displayOption: displaySome + displayPercentage: 50 + displayLimit: null + recontactDays: 7 + autoClose: null + autoComplete: null + delay: 0 + triggers: + - actionClassId: clyy1234567890123456789012 + SegmentFilterValue: + description: | + Comparison value for a segment filter condition. A string or number for most operators; a relative-date object (`{ amount, unit }`) for `isOlderThan` / `isNewerThan`; a two-element `[from, to]` string array for `isBetween`. Ignored for `isSet` / `isNotSet` but still required. + oneOf: + - type: string + - type: number + - type: object + required: + - amount + - unit + properties: + amount: + type: number + unit: + type: string + enum: + - days + - weeks + - months + - years + additionalProperties: false + - type: array + items: + type: string + minItems: 2 + maxItems: 2 + SegmentFilter: + description: | + A single targeting condition. `root.type` selects the subject and determines the valid `qualifier.operator` set: `attribute` (a workspace contact-attribute key), `person` (a built-in person identifier such as `userId`/`email`), `segment` (membership in another segment), or `device`. Each variant is one member of this union; the matching member is chosen by `root.type`. + oneOf: + - title: AttributeFilter + description: Matches on a workspace contact-attribute value. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - contactAttributeKey + properties: + type: + type: string + enum: + - attribute + contactAttributeKey: + type: string + description: A contact-attribute key defined in the workspace (e.g. `plan`, `role`). + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - lessThan + - lessEqual + - greaterThan + - greaterEqual + - equals + - notEquals + - isSet + - isNotSet + - contains + - doesNotContain + - startsWith + - endsWith + - isOlderThan + - isNewerThan + - isBefore + - isAfter + - isBetween + - isSameDay + additionalProperties: false + value: + $ref: '#/components/schemas/SegmentFilterValue' + additionalProperties: false + - title: PersonFilter + description: Matches on a built-in person identifier. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - personIdentifier + properties: + type: + type: string + enum: + - person + personIdentifier: + type: string + description: A built-in person identifier (e.g. `userId`). + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - equals + - notEquals + - isSet + - isNotSet + - contains + - doesNotContain + - startsWith + - endsWith + additionalProperties: false + value: + $ref: '#/components/schemas/SegmentFilterValue' + additionalProperties: false + - title: SegmentFilter + description: Matches on membership in another segment. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - segmentId + properties: + type: + type: string + enum: + - segment + segmentId: + type: string + description: Id of the segment to test membership against. + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - userIsIn + - userIsNotIn + additionalProperties: false + value: + $ref: '#/components/schemas/SegmentFilterValue' + additionalProperties: false + - title: DeviceFilter + description: Matches on the contact's device type. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - deviceType + properties: + type: + type: string + enum: + - device + deviceType: + type: string + description: Device type (e.g. `desktop`, `phone`). + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - equals + - notEquals + additionalProperties: false + value: + $ref: '#/components/schemas/SegmentFilterValue' + additionalProperties: false + SegmentFilters: + type: array + description: | + Contact targeting filter tree. An empty array targets everyone. Each node joins the previous one via its `connector` (`and`/`or`; the first node's connector is null). A node's `resource` is either a single filter condition or a nested filter group (enabling parenthesized `and`/`or` logic). + items: + type: object + required: + - id + - connector + - resource + properties: + id: + type: string + format: cuid2 + connector: + type: + - string + - 'null' + enum: + - and + - or + - null + description: Logical connector joining this node to the previous one. Null for the first node. + resource: + oneOf: + - $ref: '#/components/schemas/SegmentFilter' + - $ref: '#/components/schemas/SegmentFilters' + description: A single filter condition, or a nested filter group for grouped logic. + additionalProperties: false + SurveyTargeting: + type: object + description: | + App-survey contact targeting. Only valid for `type: app`. `filters: []` targets everyone. Setting or changing non-empty filters requires the Contacts entitlement; otherwise create/patch returns 403. + + `attribute` filters reference workspace contact-attribute keys (discover them via `GET /api/v3/contact-attribute-keys`) and `segment` filters reference other segment ids — both are workspace-scoped resources you must obtain before constructing filters (an invalid key/id targets no one rather than erroring). See `SegmentFilter` for the per-`root.type` operator sets. + required: + - filters + properties: + filters: + $ref: '#/components/schemas/SegmentFilters' + additionalProperties: false CreateSurveyRequest: type: object description: | Strict v3 survey creation document. This endpoint accepts survey structure only: name, metadata, languages, welcome card, blocks/elements/logic, endings, hidden fields, and variables. - It rejects legacy `questions` and out-of-scope settings such as styling, targeting, segments, - follow-ups, recaptcha, single-use/email verification, slug, custom scripts, analytics fields, - timestamps, and `createdBy`. + App surveys (`type: app`) additionally accept the `distribution` (display options + triggers) and `targeting` (contact segment filters) objects; these are rejected for `link` surveys. + It rejects legacy `questions` and out-of-scope settings such as styling, follow-ups, recaptcha, single-use/email verification, slug, custom scripts, analytics fields, timestamps, and `createdBy`. Translatable fields use real locale-code maps. The map must include the canonical `defaultLanguage` key, such as `en-US`, so the server can persist the internal default @@ -2461,8 +2923,7 @@ components: - app default: link description: | - Optional compatibility field. `link` and `app` survey types are accepted here; app/in-app - survey distribution settings remain outside this structure-focused create endpoint. + Survey type. `link` surveys are distributed by URL; `app` surveys are shown in-app and configure their runtime behavior via the `distribution` and `targeting` objects. `type` is immutable after creation (it cannot be changed via PATCH). status: type: string enum: @@ -2513,6 +2974,14 @@ components: default: [] items: $ref: '#/components/schemas/CreateSurveyVariable' + distribution: + allOf: + - $ref: '#/components/schemas/SurveyDistribution' + description: App-survey runtime/display settings. Only valid when `type` is `app`. + targeting: + allOf: + - $ref: '#/components/schemas/SurveyTargeting' + description: App-survey contact targeting. Only valid when `type` is `app`. additionalProperties: false SurveyLanguage: type: object @@ -2711,6 +3180,14 @@ components: type: array items: $ref: '#/components/schemas/SurveyVariable' + distribution: + allOf: + - $ref: '#/components/schemas/SurveyDistribution' + description: App-survey runtime/display settings. Present only for app surveys; omitted for link surveys. + targeting: + allOf: + - $ref: '#/components/schemas/SurveyTargeting' + description: App-survey contact targeting. Present only for app surveys; omitted for link surveys. AISurveyGenerationLocaleCode: type: string enum: @@ -2756,8 +3233,9 @@ components: type: string enum: - link + - app default: link - description: Link Survey is the only AI-created type until the v3 create endpoint supports additional types. + description: Survey type to generate. The AI generates the survey content (blocks/questions) for both types; for `app` it additionally seeds a default `distribution` (display once, no triggers, no targeting) that you finish configuring before publishing. language: $ref: '#/components/schemas/AISurveyGenerationLocaleCode' description: | @@ -2841,8 +3319,8 @@ components: description: | Patch payload shape. Top-level fields are partial; any provided nested object or array fully replaces that subtree. Omitted top-level fields are preserved. Immutable/system fields - such as `id`, `workspaceId`, `type`, `defaultLanguage`, timestamps, `questions`, analytics, - distribution, styling, targeting, and scripts are rejected. + such as `id`, `workspaceId`, `type`, `defaultLanguage`, timestamps, `questions`, analytics, styling, + and scripts are rejected. App surveys (stored `type: app`) additionally accept `distribution` and `targeting`; both are rejected for link surveys, and changing `targeting` filters requires the Contacts entitlement. `metadata`, `languages`, `welcomeCard`, `blocks`, `endings`, `hiddenFields`, and `variables` replace their full top-level value when provided. Missing ids in replaced arrays are deletions. @@ -2892,6 +3370,15 @@ components: type: array items: $ref: '#/components/schemas/SurveyVariable' + distribution: + allOf: + - $ref: '#/components/schemas/SurveyDistribution' + description: | + App-survey runtime/display settings. Only valid for stored `type: app`. Replaces the full distribution; omitted scalars reset to their defaults. + targeting: + allOf: + - $ref: '#/components/schemas/SurveyTargeting' + description: App-survey contact targeting. Only valid for stored app surveys. additionalProperties: false ValidateSurveyPatchRequest: type: object @@ -2969,3 +3456,154 @@ components: items: $ref: '#/components/schemas/SurveyValidationLanguage' additionalProperties: false + ActionClassResource: + type: object + description: A workspace action class that can be referenced from an app-survey trigger. + required: + - id + - name + - description + - type + - key + properties: + id: + type: string + format: cuid2 + description: Use this value as `distribution.triggers[].actionClassId` on an app survey. + name: + type: string + description: + type: + - string + - 'null' + type: + type: string + enum: + - code + - noCode + key: + type: + - string + - 'null' + description: Code-action key for code-type action classes; null for no-code actions. + additionalProperties: false + ContactAttributeKeyResource: + type: object + description: | + A workspace contact-attribute key. Use `key` as `targeting.filters[].root.contactAttributeKey` on an app survey; `dataType` indicates which operators apply (e.g. `number`/`date` attributes support arithmetic/date operators). + required: + - id + - key + - name + - description + - type + - dataType + properties: + id: + type: string + format: cuid2 + key: + type: string + description: The attribute key used in targeting filters (e.g. `plan`, `role`). + name: + type: + - string + - 'null' + description: Human-readable display name, or null. + description: + type: + - string + - 'null' + type: + type: string + enum: + - default + - custom + description: Whether this is a built-in (`default`) or workspace-defined (`custom`) attribute. + dataType: + type: string + enum: + - string + - number + - date + additionalProperties: false + responses: + V3BadRequest: + description: Bad Request — malformed JSON, invalid query/body/params, duplicate name, or unsupported field. + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + V3Unauthorized: + description: Not authenticated (no valid session or API key). + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + V3Forbidden: + description: Forbidden — no workspace access, or resource does not exist (404 not used; avoids existence leak). + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + V3TooManyRequests: + description: Rate limit exceeded. + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store + Retry-After: + schema: + type: integer + description: Seconds until the current rate-limit window resets. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + V3InternalServerError: + description: Internal Server Error. + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' diff --git a/docs/api-v3-reference/src/components/schemas/ActionClassResource.yml b/docs/api-v3-reference/src/components/schemas/ActionClassResource.yml new file mode 100644 index 000000000000..879ee57ab482 --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/ActionClassResource.yml @@ -0,0 +1,30 @@ +type: object +description: A workspace action class that can be referenced from an app-survey trigger. +required: + - id + - name + - description + - type + - key +properties: + id: + type: string + format: cuid2 + description: Use this value as `distribution.triggers[].actionClassId` on an app survey. + name: + type: string + description: + type: + - string + - "null" + type: + type: string + enum: + - code + - noCode + key: + type: + - string + - "null" + description: Code-action key for code-type action classes; null for no-code actions. +additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/ContactAttributeKeyResource.yml b/docs/api-v3-reference/src/components/schemas/ContactAttributeKeyResource.yml new file mode 100644 index 000000000000..dd517c02fdc1 --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/ContactAttributeKeyResource.yml @@ -0,0 +1,41 @@ +type: object +description: > + A workspace contact-attribute key. Use `key` as + `targeting.filters[].root.contactAttributeKey` on an app survey; `dataType` indicates which + operators apply (e.g. `number`/`date` attributes support arithmetic/date operators). +required: + - id + - key + - name + - description + - type + - dataType +properties: + id: + type: string + format: cuid2 + key: + type: string + description: The attribute key used in targeting filters (e.g. `plan`, `role`). + name: + type: + - string + - "null" + description: Human-readable display name, or null. + description: + type: + - string + - "null" + type: + type: string + enum: + - default + - custom + description: Whether this is a built-in (`default`) or workspace-defined (`custom`) attribute. + dataType: + type: string + enum: + - string + - number + - date +additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/CreateSurveyRequest.yml b/docs/api-v3-reference/src/components/schemas/CreateSurveyRequest.yml index 0fda94d2e33b..23e02576caf6 100644 --- a/docs/api-v3-reference/src/components/schemas/CreateSurveyRequest.yml +++ b/docs/api-v3-reference/src/components/schemas/CreateSurveyRequest.yml @@ -6,13 +6,11 @@ description: > metadata, languages, welcome card, blocks/elements/logic, endings, hidden fields, and variables. - It rejects legacy `questions` and out-of-scope settings such as styling, - targeting, segments, + App surveys (`type: app`) additionally accept the `distribution` (display options + triggers) and + `targeting` (contact segment filters) objects; these are rejected for `link` surveys. - follow-ups, recaptcha, single-use/email verification, slug, custom scripts, - analytics fields, - - timestamps, and `createdBy`. + It rejects legacy `questions` and out-of-scope settings such as styling, follow-ups, recaptcha, + single-use/email verification, slug, custom scripts, analytics fields, timestamps, and `createdBy`. Translatable fields use real locale-code maps. The map must include the @@ -60,11 +58,9 @@ properties: - app default: link description: > - Optional compatibility field. `link` and `app` survey types are accepted - here; app/in-app - - survey distribution settings remain outside this structure-focused create - endpoint. + Survey type. `link` surveys are distributed by URL; `app` surveys are shown in-app and configure + their runtime behavior via the `distribution` and `targeting` objects. `type` is immutable after + creation (it cannot be changed via PATCH). status: type: string enum: @@ -117,4 +113,12 @@ properties: default: [] items: $ref: ./CreateSurveyVariable.yml + distribution: + allOf: + - $ref: ./SurveyDistribution.yml + description: App-survey runtime/display settings. Only valid when `type` is `app`. + targeting: + allOf: + - $ref: ./SurveyTargeting.yml + description: App-survey contact targeting. Only valid when `type` is `app`. additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/GenerateSurveyRequest.yml b/docs/api-v3-reference/src/components/schemas/GenerateSurveyRequest.yml index 585476585594..85b5c3c9326e 100644 --- a/docs/api-v3-reference/src/components/schemas/GenerateSurveyRequest.yml +++ b/docs/api-v3-reference/src/components/schemas/GenerateSurveyRequest.yml @@ -31,10 +31,12 @@ properties: type: string enum: - link + - app default: link description: >- - Link Survey is the only AI-created type until the v3 create endpoint - supports additional types. + Survey type to generate. The AI generates the survey content (blocks/questions) for both types; + for `app` it additionally seeds a default `distribution` (display once, no triggers, no targeting) + that you finish configuring before publishing. language: $ref: ./AISurveyGenerationLocaleCode.yml description: > diff --git a/docs/api-v3-reference/src/components/schemas/PatchSurveyRequest.yml b/docs/api-v3-reference/src/components/schemas/PatchSurveyRequest.yml index aef63d1ee880..e50c50277e72 100644 --- a/docs/api-v3-reference/src/components/schemas/PatchSurveyRequest.yml +++ b/docs/api-v3-reference/src/components/schemas/PatchSurveyRequest.yml @@ -8,9 +8,11 @@ description: > Immutable/system fields such as `id`, `workspaceId`, `type`, `defaultLanguage`, timestamps, - `questions`, analytics, + `questions`, analytics, styling, - distribution, styling, targeting, and scripts are rejected. + and scripts are rejected. App surveys (stored `type: app`) additionally accept `distribution` and + `targeting`; both are rejected for link surveys, and changing `targeting` filters requires the + Contacts entitlement. `metadata`, `languages`, `welcomeCard`, `blocks`, `endings`, `hiddenFields`, @@ -80,4 +82,14 @@ properties: type: array items: $ref: ./SurveyVariable.yml + distribution: + allOf: + - $ref: ./SurveyDistribution.yml + description: > + App-survey runtime/display settings. Only valid for stored `type: app`. Replaces the full + distribution; omitted scalars reset to their defaults. + targeting: + allOf: + - $ref: ./SurveyTargeting.yml + description: App-survey contact targeting. Only valid for stored app surveys. additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/Problem.yml b/docs/api-v3-reference/src/components/schemas/Problem.yml index 59fee8f077c5..9828b30890a9 100644 --- a/docs/api-v3-reference/src/components/schemas/Problem.yml +++ b/docs/api-v3-reference/src/components/schemas/Problem.yml @@ -32,6 +32,8 @@ properties: - forbidden - internal_server_error - not_authenticated + - not_found + - payload_too_large - too_many_requests - unprocessable_content requestId: diff --git a/docs/api-v3-reference/src/components/schemas/SegmentFilter.yml b/docs/api-v3-reference/src/components/schemas/SegmentFilter.yml new file mode 100644 index 000000000000..f77d4f0617d0 --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/SegmentFilter.yml @@ -0,0 +1,188 @@ +description: > + A single targeting condition. `root.type` selects the subject and determines the valid + `qualifier.operator` set: `attribute` (a workspace contact-attribute key), `person` (a built-in + person identifier such as `userId`/`email`), `segment` (membership in another segment), or `device`. + Each variant is one member of this union; the matching member is chosen by `root.type`. +oneOf: + - title: AttributeFilter + description: Matches on a workspace contact-attribute value. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - contactAttributeKey + properties: + type: + type: string + enum: + - attribute + contactAttributeKey: + type: string + description: A contact-attribute key defined in the workspace (e.g. `plan`, `role`). + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - lessThan + - lessEqual + - greaterThan + - greaterEqual + - equals + - notEquals + - isSet + - isNotSet + - contains + - doesNotContain + - startsWith + - endsWith + - isOlderThan + - isNewerThan + - isBefore + - isAfter + - isBetween + - isSameDay + additionalProperties: false + value: + $ref: ./SegmentFilterValue.yml + additionalProperties: false + - title: PersonFilter + description: Matches on a built-in person identifier. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - personIdentifier + properties: + type: + type: string + enum: + - person + personIdentifier: + type: string + description: A built-in person identifier (e.g. `userId`). + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - equals + - notEquals + - isSet + - isNotSet + - contains + - doesNotContain + - startsWith + - endsWith + additionalProperties: false + value: + $ref: ./SegmentFilterValue.yml + additionalProperties: false + - title: SegmentFilter + description: Matches on membership in another segment. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - segmentId + properties: + type: + type: string + enum: + - segment + segmentId: + type: string + description: Id of the segment to test membership against. + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - userIsIn + - userIsNotIn + additionalProperties: false + value: + $ref: ./SegmentFilterValue.yml + additionalProperties: false + - title: DeviceFilter + description: Matches on the contact's device type. + type: object + required: + - id + - root + - qualifier + - value + properties: + id: + type: string + format: cuid2 + root: + type: object + required: + - type + - deviceType + properties: + type: + type: string + enum: + - device + deviceType: + type: string + description: Device type (e.g. `desktop`, `phone`). + additionalProperties: false + qualifier: + type: object + required: + - operator + properties: + operator: + type: string + enum: + - equals + - notEquals + additionalProperties: false + value: + $ref: ./SegmentFilterValue.yml + additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/SegmentFilterValue.yml b/docs/api-v3-reference/src/components/schemas/SegmentFilterValue.yml new file mode 100644 index 000000000000..e06877d30c68 --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/SegmentFilterValue.yml @@ -0,0 +1,27 @@ +description: > + Comparison value for a segment filter condition. A string or number for most operators; a + relative-date object (`{ amount, unit }`) for `isOlderThan` / `isNewerThan`; a two-element + `[from, to]` string array for `isBetween`. Ignored for `isSet` / `isNotSet` but still required. +oneOf: + - type: string + - type: number + - type: object + required: + - amount + - unit + properties: + amount: + type: number + unit: + type: string + enum: + - days + - weeks + - months + - years + additionalProperties: false + - type: array + items: + type: string + minItems: 2 + maxItems: 2 diff --git a/docs/api-v3-reference/src/components/schemas/SegmentFilters.yml b/docs/api-v3-reference/src/components/schemas/SegmentFilters.yml new file mode 100644 index 000000000000..f39655f4b017 --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/SegmentFilters.yml @@ -0,0 +1,30 @@ +type: array +description: > + Contact targeting filter tree. An empty array targets everyone. Each node joins the previous one via + its `connector` (`and`/`or`; the first node's connector is null). A node's `resource` is either a + single filter condition or a nested filter group (enabling parenthesized `and`/`or` logic). +items: + type: object + required: + - id + - connector + - resource + properties: + id: + type: string + format: cuid2 + connector: + type: + - string + - "null" + enum: + - and + - or + - null + description: Logical connector joining this node to the previous one. Null for the first node. + resource: + oneOf: + - $ref: ./SegmentFilter.yml + - $ref: ./SegmentFilters.yml + description: A single filter condition, or a nested filter group for grouped logic. + additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml b/docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml new file mode 100644 index 000000000000..45cf022a10d6 --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/SurveyDistribution.yml @@ -0,0 +1,86 @@ +type: object +description: > + App-survey runtime/display settings. Only valid for `type: app`; rejected for link surveys. + + **Replacement semantics (destructive):** when `distribution` is provided on PATCH it fully replaces + the stored object — any sub-field you omit is reset to its default, NOT preserved. In particular, + omitting `triggers` removes ALL existing triggers, and omitting `displayOption` resets it to + `displayOnce`. To change one setting, send the complete desired distribution. To leave distribution + untouched, omit the whole `distribution` key. +properties: + displayOption: + type: string + enum: + - displayOnce + - displayMultiple + - respondMultiple + - displaySome + default: displayOnce + description: > + How often the survey may be shown to a contact. `displaySome` shows it to `displayPercentage` + of contacts. + displayPercentage: + type: + - number + - "null" + minimum: 0.01 + maximum: 100 + default: null + description: > + Percentage of contacts to show the survey to. Required when `displayOption` is `displaySome`; + must be null/omitted otherwise. + displayLimit: + type: + - integer + - "null" + minimum: 0 + default: null + description: Maximum number of times the survey is shown to a single contact. + recontactDays: + type: + - integer + - "null" + minimum: 0 + default: null + description: Days to wait before showing this survey again to the same contact. Null uses the workspace default. + autoClose: + type: + - integer + - "null" + minimum: 0 + default: null + description: Seconds of inactivity after which the survey widget auto-closes. Null disables auto-close. + autoComplete: + type: + - integer + - "null" + minimum: 1 + default: null + description: Automatically stop collecting responses after this many completed responses. Null disables it. + delay: + type: integer + minimum: 0 + default: 0 + description: Seconds to wait after a trigger fires before showing the survey. + triggers: + type: array + default: [] + items: + $ref: ./SurveyTrigger.yml + description: > + Action classes that trigger the survey. Every id must reference an existing workspace action + class. On PATCH this list fully replaces the survey's triggers — omitting it (or sending `[]`) + removes all existing triggers. +additionalProperties: false +example: + # `displaySome` requires `displayPercentage` (shown to that % of contacts); any other displayOption + # requires `displayPercentage` to be null/omitted. + displayOption: displaySome + displayPercentage: 50 + displayLimit: null + recontactDays: 7 + autoClose: null + autoComplete: null + delay: 0 + triggers: + - actionClassId: clyy1234567890123456789012 diff --git a/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml b/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml index d65543ef0e7d..18c89b4e1d97 100644 --- a/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml +++ b/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml @@ -7,6 +7,17 @@ description: > Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`. +required: + - id + - name + - workspaceId + - type + - status + - publishOn + - createdAt + - updatedAt + - responseCount + - creator properties: id: type: string @@ -34,21 +45,22 @@ properties: updatedAt: type: string format: date-time + publishOn: + type: + - string + - 'null' + format: date-time + description: Scheduled publish time (ISO 8601), or null if not scheduled. responseCount: type: integer creator: type: - object - 'null' + description: The user who created the survey, or null for API-key/system-created surveys. + required: + - name properties: name: type: string - singleUse: - type: - - object - - 'null' - properties: - enabled: - type: boolean - isEncrypted: - type: boolean + additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/SurveyResource.yml b/docs/api-v3-reference/src/components/schemas/SurveyResource.yml index 2c0b7d6f8b2f..7c0789ecbfe1 100644 --- a/docs/api-v3-reference/src/components/schemas/SurveyResource.yml +++ b/docs/api-v3-reference/src/components/schemas/SurveyResource.yml @@ -71,3 +71,11 @@ properties: type: array items: $ref: ./SurveyVariable.yml + distribution: + allOf: + - $ref: ./SurveyDistribution.yml + description: App-survey runtime/display settings. Present only for app surveys; omitted for link surveys. + targeting: + allOf: + - $ref: ./SurveyTargeting.yml + description: App-survey contact targeting. Present only for app surveys; omitted for link surveys. diff --git a/docs/api-v3-reference/src/components/schemas/SurveyTargeting.yml b/docs/api-v3-reference/src/components/schemas/SurveyTargeting.yml new file mode 100644 index 000000000000..c3fdddf3c683 --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/SurveyTargeting.yml @@ -0,0 +1,16 @@ +type: object +description: > + App-survey contact targeting. Only valid for `type: app`. `filters: []` targets everyone. Setting or + changing non-empty filters requires the Contacts entitlement; otherwise create/patch returns 403. + + + `attribute` filters reference workspace contact-attribute keys (discover them via + `GET /api/v3/contact-attribute-keys`) and `segment` filters reference other segment ids — both are + workspace-scoped resources you must obtain before constructing filters (an invalid key/id targets no + one rather than erroring). See `SegmentFilter` for the per-`root.type` operator sets. +required: + - filters +properties: + filters: + $ref: ./SegmentFilters.yml +additionalProperties: false diff --git a/docs/api-v3-reference/src/components/schemas/SurveyTrigger.yml b/docs/api-v3-reference/src/components/schemas/SurveyTrigger.yml new file mode 100644 index 000000000000..3fac49d7dcba --- /dev/null +++ b/docs/api-v3-reference/src/components/schemas/SurveyTrigger.yml @@ -0,0 +1,12 @@ +type: object +description: > + Reference to an existing workspace action class that triggers this app survey. Discover available + action-class ids with `GET /api/v3/action-classes`. +required: + - actionClassId +properties: + actionClassId: + type: string + format: cuid2 + description: Id of an action class in the same workspace. +additionalProperties: false diff --git a/docs/api-v3-reference/src/openapi.yml b/docs/api-v3-reference/src/openapi.yml index 10cb96d32f02..b0b9fe9e3932 100644 --- a/docs/api-v3-reference/src/openapi.yml +++ b/docs/api-v3-reference/src/openapi.yml @@ -24,8 +24,16 @@ info: **workspaceId** - Query param `workspaceId` is the canonical container identifier for this - API. + `workspaceId` is the canonical container identifier for this API. It is a + **required query parameter on collection read endpoints** (`GET + /api/v3/surveys`, `GET /api/v3/action-classes`, `GET + /api/v3/contact-attribute-keys`) because those operate within a workspace. + **`POST /api/v3/surveys` instead requires `workspaceId` in the request + body**, not as a query parameter. Single-resource endpoints + (`GET`/`PATCH`/`DELETE /api/v3/surveys/{surveyId}`) locate the resource by + its globally-unique id and resolve the workspace from it, so they do **not** + accept a `workspaceId` query parameter — sending one returns **400** + (`Unrecognized key: "workspaceId"`). **Auth** @@ -67,9 +75,10 @@ info: **AI survey creation** `POST /api/v3/surveys/generate` returns a draft `POST /api/v3/surveys` - payload plus validation metadata. It does not create a survey. Generated - payloads are currently link-survey only because the v3 create contract - accepts only `type: link`. + payload plus validation metadata. It does not create a survey. It generates + survey content for both `link` and `app` surveys; for `app` it seeds a + default `distribution` (display once, no triggers, no targeting) that you + finish configuring before publishing. Prompt text is sent to the configured AI provider for generation, but it is not stored by this endpoint, not logged by default, and not included in @@ -122,6 +131,10 @@ paths: $ref: paths/api_v3_surveys_validate.yml /api/v3/surveys/{surveyId}: $ref: paths/api_v3_surveys_{surveyId}.yml + /api/v3/action-classes: + $ref: paths/api_v3_action-classes.yml + /api/v3/contact-attribute-keys: + $ref: paths/api_v3_contact-attribute-keys.yml components: securitySchemes: sessionAuth: diff --git a/docs/api-v3-reference/src/paths/api_v3_action-classes.yml b/docs/api-v3-reference/src/paths/api_v3_action-classes.yml new file mode 100644 index 000000000000..b1407e9b74b4 --- /dev/null +++ b/docs/api-v3-reference/src/paths/api_v3_action-classes.yml @@ -0,0 +1,90 @@ +get: + operationId: listActionClassesV3 + summary: List action classes + description: | + Returns the action classes (user actions that can trigger an app survey) for a workspace. Use + the returned ids as `distribution.triggers[].actionClassId` when creating or updating app + surveys. Read-only: this v3 endpoint lists action classes but does not create them — action + classes are managed in the Formbricks app (Actions settings) or via the v1/v2 management API. + + An empty `data` array means the workspace has no action classes yet. Triggers are optional, so + you can still create an app survey with `distribution.triggers: []` (or omit `distribution` + entirely) and attach triggers later via `PATCH` once an action class exists. Session cookie or + x-api-key. + tags: + - V3 Action Classes + parameters: + - in: query + name: workspaceId + required: true + schema: + type: string + format: cuid2 + description: | + Workspace identifier. This is the canonical container ID for v3 APIs. + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + description: Page size (max 100). + - in: query + name: cursor + schema: + type: string + description: > + Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first + request. + responses: + "200": + description: Action classes retrieved successfully + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: ../components/schemas/ActionClassResource.yml + meta: + type: object + required: + - limit + - nextCursor + properties: + limit: + type: integer + nextCursor: + type: + - string + - "null" + description: >- + Opaque cursor for the next page. `null` when there are no more results. + "400": + $ref: ../components/responses/V3BadRequest.yml + "401": + $ref: ../components/responses/V3Unauthorized.yml + "403": + $ref: ../components/responses/V3Forbidden.yml + "429": + $ref: ../components/responses/V3TooManyRequests.yml + "500": + $ref: ../components/responses/V3InternalServerError.yml + security: + - sessionAuth: [] + - apiKeyAuth: [] diff --git a/docs/api-v3-reference/src/paths/api_v3_contact-attribute-keys.yml b/docs/api-v3-reference/src/paths/api_v3_contact-attribute-keys.yml new file mode 100644 index 000000000000..3cef6b9d35cf --- /dev/null +++ b/docs/api-v3-reference/src/paths/api_v3_contact-attribute-keys.yml @@ -0,0 +1,85 @@ +get: + operationId: listContactAttributeKeysV3 + summary: List contact attribute keys + description: | + Returns the contact attribute keys for a workspace. Use the returned `key` values as + `targeting.filters[].root.contactAttributeKey` when targeting app surveys; `dataType` hints which + operators apply. Session cookie or x-api-key. (Applying targeting filters requires the Contacts + entitlement; this discovery endpoint does not.) + tags: + - V3 Contact Attribute Keys + parameters: + - in: query + name: workspaceId + required: true + schema: + type: string + format: cuid2 + description: | + Workspace identifier. This is the canonical container ID for v3 APIs. + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + description: Page size (max 100). + - in: query + name: cursor + schema: + type: string + description: > + Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first + request. + responses: + "200": + description: Contact attribute keys retrieved successfully + headers: + X-Request-Id: + schema: + type: string + description: Request correlation ID + Cache-Control: + schema: + type: string + example: private, no-store + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: ../components/schemas/ContactAttributeKeyResource.yml + meta: + type: object + required: + - limit + - nextCursor + properties: + limit: + type: integer + nextCursor: + type: + - string + - "null" + description: >- + Opaque cursor for the next page. `null` when there are no more results. + "400": + $ref: ../components/responses/V3BadRequest.yml + "401": + $ref: ../components/responses/V3Unauthorized.yml + "403": + $ref: ../components/responses/V3Forbidden.yml + "429": + $ref: ../components/responses/V3TooManyRequests.yml + "500": + $ref: ../components/responses/V3InternalServerError.yml + security: + - sessionAuth: [] + - apiKeyAuth: [] 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 fccbfdabdc7a..3e7792a21a25 100644 --- a/docs/api-v3-reference/src/paths/api_v3_surveys.yml +++ b/docs/api-v3-reference/src/paths/api_v3_surveys.yml @@ -139,42 +139,15 @@ get: Total number of surveys matching the current filters across all pages. `null` when `includeTotalCount=false`. '400': - description: Bad Request - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3BadRequest.yml '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Unauthorized.yml '403': - description: >- - Forbidden — no access, or workspace does not exist (404 not used; avoids - existence leak) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Forbidden.yml '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3TooManyRequests.yml '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3InternalServerError.yml security: - sessionAuth: [] - apiKeyAuth: [] @@ -217,13 +190,12 @@ post: `invalid_reference`. - This first write surface intentionally stays structure-focused: `type` may - be omitted or set - - to `link` or `app`, but in-app survey creation and distribution-channel - settings are not part - - of this operation. Unsupported fields are rejected instead of ignored. + `type` may be omitted or set to `link` or `app`. App surveys additionally accept the + `distribution` (display options + triggers) and `targeting` (contact segment filters) objects; + both are rejected for link surveys. Trigger ids must reference existing workspace action classes + (discover them via `GET /api/v3/action-classes`), and `targeting.filters` references + (contact-attribute keys and segments) must exist in the workspace. Unsupported fields are + rejected instead of ignored. tags: - V3 Surveys requestBody: @@ -266,6 +238,74 @@ post: hiddenFields: enabled: false variables: [] + appSurvey: + summary: Create an in-app (app) survey with distribution and triggers + description: > + App surveys are shown inside your web or mobile app. `distribution` controls display + behavior and the action classes that trigger the survey (by id — discover ids via + `GET /api/v3/action-classes`). `targeting.filters` scopes the audience; an empty array + targets everyone. `type` is immutable after creation. + value: + workspaceId: clxx1234567890123456789012 + name: In-App Onboarding Feedback + type: app + status: draft + defaultLanguage: en-US + blocks: + - name: Onboarding + elements: + - id: onboarding_feedback + type: openText + headline: + en-US: How was your setup experience? + required: false + endings: [] + hiddenFields: + enabled: false + variables: [] + distribution: + displayOption: respondMultiple + recontactDays: 7 + delay: 5 + triggers: + - actionClassId: clyy1234567890123456789012 + targeting: + # Target: plan = pro AND (role = admin OR role = owner). A node's `resource` is either a + # single condition or a nested group (array); the first node's `connector` is null. + filters: + - id: clf0aaaaaaaaaaaaaaaaaaaa01 + connector: null + resource: + id: clf1aaaaaaaaaaaaaaaaaaaa01 + root: + type: attribute + contactAttributeKey: plan + qualifier: + operator: equals + value: pro + - id: clf2aaaaaaaaaaaaaaaaaaaa01 + connector: and + resource: + - id: clf3aaaaaaaaaaaaaaaaaaaa01 + connector: null + resource: + id: clf4aaaaaaaaaaaaaaaaaaaa01 + root: + type: attribute + contactAttributeKey: role + qualifier: + operator: equals + value: admin + - id: clf5aaaaaaaaaaaaaaaaaaaa01 + connector: or + resource: + id: clf6aaaaaaaaaaaaaaaaaaaa01 + root: + type: attribute + contactAttributeKey: role + qualifier: + operator: equals + value: owner sequentialProductSurvey: summary: Create a richer sequential survey without logicFallback description: > @@ -368,45 +408,52 @@ post: $ref: ../components/schemas/SurveyResource.yml '400': description: > - Bad Request — invalid JSON, unsupported fields, malformed multilingual - maps, duplicate - - stable ids, or dangling logic/reference ids. + 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`. content: application/problem+json: schema: $ref: ../components/schemas/Problem.yml '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Unauthorized.yml '403': description: >- - Forbidden — no write access, missing external URL permission, or - workspace does not exist (404 not used; avoids existence leak) + Forbidden — no write access, missing external URL permission, missing + Contacts entitlement for app-survey targeting, or workspace does not + exist (404 not used; avoids existence leak) content: application/problem+json: schema: $ref: ../components/schemas/Problem.yml - '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets + '422': + description: >- + Unprocessable Content — the document passed schema validation but failed a cross-reference + check that requires stored state to detect: duplicate stable ids, dangling logic/jump + references, undeclared locale keys used in content, invalid media URLs, a + `distribution.triggers[].actionClassId` referencing an action class that does not exist in + the workspace (discover valid ids via `GET /api/v3/action-classes`), or a `targeting.filters` + entry that cannot resolve in the workspace — an unknown contact-attribute key or segment, an + unsupported person identifier, or an unknown device value. The `invalid_params` + array pinpoints each issue (e.g. `code: invalid_reference` with the offending `identifier`). content: application/problem+json: schema: $ref: ../components/schemas/Problem.yml + '429': + $ref: ../components/responses/V3TooManyRequests.yml '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3InternalServerError.yml security: - sessionAuth: [] - apiKeyAuth: [] diff --git a/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml b/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml index bb6ee3a8b4d1..5ba112f0b57c 100644 --- a/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml +++ b/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml @@ -15,13 +15,14 @@ post: /api/v3/surveys`. - The generated payload always sets `status: draft`. The initial contract - supports only + The generated payload always sets `status: draft`. Both `type: link` and + `type: app` are - `type: link`; app and website survey creation must stay disabled until the - v3 create + supported; for `app`, the payload includes a default `distribution` (display + once, no triggers, - endpoint accepts those types. + no targeting) that you finish configuring in the editor or via the create + request. Prompt privacy: prompt text is sent to the configured AI provider to create @@ -143,11 +144,7 @@ post: Describe the survey goal, audience, or topic in a sentence so the AI can create a useful draft. '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Unauthorized.yml '403': description: >- Forbidden — no write access, workspace does not exist, AI is not in @@ -173,6 +170,14 @@ post: detail: AI smart tools are disabled for this organization. code: ai_smart_tools_disabled requestId: req_123 + aiNotInPlan: + summary: AI features not enabled for the plan + value: + title: AI Unavailable + status: 403 + detail: AI features are not enabled for this organization's plan. + code: ai_features_not_enabled + requestId: req_123 '422': description: AI generated an invalid payload after schema and v3 create validation content: @@ -194,22 +199,9 @@ post: - name: generatedSurvey.blocks reason: Too small '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3TooManyRequests.yml '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3InternalServerError.yml '502': description: The configured AI provider failed or could not produce a valid draft content: diff --git a/docs/api-v3-reference/src/paths/api_v3_surveys_validate.yml b/docs/api-v3-reference/src/paths/api_v3_surveys_validate.yml index b29caa47c2de..398e05a88b2a 100644 --- a/docs/api-v3-reference/src/paths/api_v3_surveys_validate.yml +++ b/docs/api-v3-reference/src/paths/api_v3_surveys_validate.yml @@ -111,34 +111,13 @@ post: schema: $ref: ../components/schemas/Problem.yml '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Unauthorized.yml '403': - description: Forbidden — no write access, or survey/workspace does not exist - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Forbidden.yml '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3TooManyRequests.yml '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3InternalServerError.yml security: - sessionAuth: [] - apiKeyAuth: [] 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 6877b8d82e0e..22e96638a6cf 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 @@ -171,36 +171,13 @@ get: schema: $ref: ../components/schemas/Problem.yml '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Unauthorized.yml '403': - description: >- - Forbidden — no access, or survey does not exist (404 not used; avoids - existence leak) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Forbidden.yml '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3TooManyRequests.yml '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3InternalServerError.yml security: - sessionAuth: [] - apiKeyAuth: [] @@ -313,48 +290,53 @@ patch: $ref: ../components/schemas/SurveyResource.yml '400': description: > - Bad Request — malformed JSON, unsupported query parameter, unsupported - field, - - invalid locale map, duplicate id, immutable element id change, dangling - reference, - - or invalid survey document. + 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`. content: application/problem+json: schema: $ref: ../components/schemas/Problem.yml '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Unauthorized.yml '403': description: >- - Forbidden — no write access, or survey does not exist (404 not used; - avoids existence leak) + Forbidden — no write access, missing external URL permission, missing + Contacts entitlement for app-survey targeting, or survey does not exist + (404 not used; avoids existence leak) content: application/problem+json: schema: $ref: ../components/schemas/Problem.yml - '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets + '422': + description: >- + Unprocessable Content — the patched document passed schema validation but failed a + cross-reference check that requires stored state to detect: an undeclared locale key used in + content, a duplicate stable id, an immutable element-id change on a published survey, a + dangling logic/jump reference, an invalid media URL, a + `distribution.triggers[].actionClassId` referencing an action class that does not exist in + the workspace (discover valid ids via `GET /api/v3/action-classes`), or a `targeting.filters` + entry that cannot resolve in the workspace — an unknown contact-attribute key or segment, an + unsupported person identifier, or an unknown device value. The `invalid_params` + array pinpoints each issue (e.g. `code: invalid_reference` with the offending `identifier`). content: application/problem+json: schema: $ref: ../components/schemas/Problem.yml + '429': + $ref: ../components/responses/V3TooManyRequests.yml '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3InternalServerError.yml security: - sessionAuth: [] - apiKeyAuth: [] @@ -385,42 +367,15 @@ delete: type: string example: private, no-store '400': - description: Bad Request - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3BadRequest.yml '401': - description: Not authenticated (no valid session or API key) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Unauthorized.yml '403': - description: >- - Forbidden — no access, or survey does not exist (404 not used; avoids - existence leak) - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3Forbidden.yml '429': - description: Rate limit exceeded - headers: - Retry-After: - schema: - type: integer - description: Seconds until the current rate-limit window resets - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3TooManyRequests.yml '500': - description: Internal Server Error - content: - application/problem+json: - schema: - $ref: ../components/schemas/Problem.yml + $ref: ../components/responses/V3InternalServerError.yml security: - sessionAuth: [] - apiKeyAuth: [] From 220c503a9379f048316d04a4dc04192444871543 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:15:01 +0200 Subject: [PATCH 2/3] refactor: fix vulnerable dependencies for tar and form-data (#8343) --- pnpm-lock.yaml | 90 ++++++++++++++++++++++++++++++--------------- pnpm-workspace.yaml | 17 +++++---- 2 files changed, 70 insertions(+), 37 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 180da3f7884a..10b859a05ec2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,8 @@ overrides: dompurify: 3.4.9 esbuild: 0.28.1 undici@7: 7.28.0 - form-data@4: 4.0.5 + form-data@4: 4.0.6 + tar: 7.5.16 patchedDependencies: next-auth@4.24.13: 6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798 @@ -2396,6 +2397,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6868,6 +6873,10 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chromatic@13.3.4: resolution: {integrity: sha512-TR5rvyH0ESXobBB3bV8jc87AEAFQC7/n+Eb4XWhJz6hW3YNxIQPVjcbgLv+a4oKHEl1dUBueWSoIQsOVGTd+RQ==} hasBin: true @@ -8003,8 +8012,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} formdata-polyfill@4.0.10: @@ -8271,6 +8280,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -9242,10 +9255,6 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -9254,6 +9263,10 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mixpanel@0.20.0: resolution: {integrity: sha512-va/dizV9tWgumKU3WQR2ZbMsb3NDlSaF7enDwtRiVIz2HCu7Cl3dq8BMV7ufB5LiNWtgtoELHsJvJv+hGhpvxw==} engines: {node: '>=10.0'} @@ -11211,10 +11224,9 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + engines: {node: '>=18'} tarn@3.0.2: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} @@ -12094,6 +12106,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} @@ -14204,6 +14220,11 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + optional: true + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.10 @@ -16709,7 +16730,7 @@ snapshots: colorette: 1.4.0 core-js: 3.48.0 dotenv: 16.6.1 - form-data: 4.0.5 + form-data: 4.0.6 get-port-please: 3.2.0 glob: 7.2.3 handlebars: 4.7.9 @@ -16757,7 +16778,7 @@ snapshots: concat-stream: 2.0.0 cookie: 0.7.2 dotenv: 16.4.5 - form-data: 4.0.5 + form-data: 4.0.6 jest-diff: 29.7.0 jest-matcher-utils: 29.7.0 js-yaml: 4.1.0 @@ -19243,7 +19264,7 @@ snapshots: axios@1.16.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -19438,7 +19459,7 @@ snapshots: promise-inflight: 1.0.1 rimraf: 3.0.2 ssri: 8.0.1 - tar: 6.2.1 + tar: 7.5.16 unique-filename: 1.1.1 transitivePeerDependencies: - bluebird @@ -19521,6 +19542,9 @@ snapshots: chownr@2.0.0: optional: true + chownr@3.0.0: + optional: true + chromatic@13.3.4: {} chrome-trace-event@1.0.4: {} @@ -20818,12 +20842,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 formdata-polyfill@4.0.10: @@ -21128,6 +21152,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} heic-convert@2.1.0: @@ -22086,9 +22114,6 @@ snapshots: yallist: 4.0.0 optional: true - minipass@5.0.0: - optional: true - minipass@7.1.3: {} minizlib@2.1.2: @@ -22097,6 +22122,11 @@ snapshots: yallist: 4.0.0 optional: true + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + optional: true + mixpanel@0.20.0: dependencies: https-proxy-agent: 7.0.6 @@ -22350,7 +22380,7 @@ snapshots: npmlog: 6.0.2 rimraf: 3.0.2 semver: 7.8.0 - tar: 6.2.1 + tar: 7.5.16 which: 2.0.2 transitivePeerDependencies: - bluebird @@ -24047,7 +24077,7 @@ snapshots: bindings: 1.5.0 node-addon-api: 7.1.1 prebuild-install: 7.1.3 - tar: 6.2.1 + tar: 7.5.16 optionalDependencies: node-gyp: 8.4.1 transitivePeerDependencies: @@ -24363,14 +24393,13 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@6.2.1: + tar@7.5.16: dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 optional: true tarn@3.0.2: {} @@ -25262,6 +25291,9 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: + optional: true + yaml-ast-parser@0.0.43: {} yaml@1.10.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f35285e1e1a5..6244da7a5235 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,8 +3,8 @@ packages: - "packages/*" allowBuilds: - '@prisma/engines': true - '@sentry/cli': true + "@prisma/engines": true + "@sentry/cli": true better-sqlite3: true core-js: true esbuild: true @@ -14,7 +14,7 @@ allowBuilds: sharp: true sqlite3: true unrs-resolver: true - '@prisma/client': true + "@prisma/client": true # Security overrides for transitive dependencies that still fail audit without a pin. # Remove each override once its upstream dependency chain adopts a patched version. @@ -28,10 +28,10 @@ overrides: # OpenTelemetry / protobuf chains. protobufjs@7: 7.6.3 protobufjs@8: 8.6.3 - # sqlite3 / node-gyp chain: tar and @tootallnate/once advisories (ENG-1456/1396/1379) are deferred, - # not pinned. The only patched releases (tar@7.5.10, @tootallnate/once@2+) cross a major and break - # node-gyp@8 / http-proxy-agent@4. Not exploitable here: tar only unpacks trusted npm-registry - # tarballs, and @tootallnate/once is Low. Re-pin once those parents adopt the patched majors. + # sqlite3 / node-gyp chain: tar advisory pins are safe here because sqlite3 is pulled in + # transitively by typeorm but the project uses postgres; sqlite3 is never built at runtime. + # Therefore node-gyp@8 / http-proxy-agent@4 compatibility is not a concern. + # `@tootallnate/once` advisory (ENG-1456/1396/1379) remains Low and is not pinned. # SAML and XML processing chains. "@xmldom/xmldom": 0.9.10 # load-bearing: 0.8.x/0.9.8 vulnerable (GHSA-2v35-w6hq-6mfw + 4 more) axios: 1.16.0 @@ -66,7 +66,8 @@ overrides: undici@7: 7.28.0 # ENG-1513 / GHSA-fjxv-7rqg-78g4: form-data <4.0.4 uses an unsafe random function for the # multipart boundary. @redocly/respect-core pins 4.0.0; patched in 4.0.4+. - form-data@4: 4.0.5 + form-data@4: 4.0.6 + tar: 7.5.16 patchedDependencies: next-auth@4.24.13: patches/next-auth@4.24.13.patch From 1556802f633a637d66a679ceb76fa5043d425b87 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:01:35 +0530 Subject: [PATCH 3/3] fix(security): bump nodemailer to 9.0.1 (ENG-1511, ENG-1507) (#8327) --- apps/web/package.json | 2 +- pnpm-lock.yaml | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index f36b05ac4d27..ceb23341a522 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -105,7 +105,7 @@ "next": "16.2.6", "next-auth": "4.24.13", "next-safe-action": "8.1.10", - "nodemailer": "8.0.7", + "nodemailer": "9.0.1", "otplib": "12.0.1", "papaparse": "5.5.3", "posthog-js": "1.369.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10b859a05ec2..35f88cbc4fae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,7 +214,7 @@ importers: version: 1.26.0(zod@4.3.6) '@next-auth/prisma-adapter': specifier: 1.0.7 - version: 1.0.7(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@8.0.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + version: 1.0.7(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@9.0.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@opentelemetry/auto-instrumentations-node': specifier: 0.75.0 version: 0.75.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0)) @@ -376,13 +376,13 @@ importers: version: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next-auth: specifier: 4.24.13 - version: 4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@8.0.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@9.0.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next-safe-action: specifier: 8.1.10 version: 8.1.10(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) nodemailer: - specifier: 8.0.7 - version: 8.0.7 + specifier: 9.0.1 + version: 9.0.1 otplib: specifier: 12.0.1 version: 12.0.1 @@ -9533,8 +9533,8 @@ packages: resolution: {integrity: sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==} engines: {node: '>=18'} - nodemailer@8.0.7: - resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} + nodemailer@9.0.1: + resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} engines: {node: '>=6.0.0'} nopt@5.0.0: @@ -14562,10 +14562,10 @@ snapshots: '@neoconfetti/react@1.0.0': {} - '@next-auth/prisma-adapter@1.0.7(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@8.0.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': + '@next-auth/prisma-adapter@1.0.7(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@9.0.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': dependencies: '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) - next-auth: 4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@8.0.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + next-auth: 4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@9.0.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@next/env@16.2.6': {} @@ -22286,7 +22286,7 @@ snapshots: neo-async@2.6.2: {} - next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@8.0.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(nodemailer@9.0.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/runtime': 7.28.4 '@panva/hkdf': 1.2.1 @@ -22301,7 +22301,7 @@ snapshots: react-dom: 19.2.6(react@19.2.6) uuid: 11.1.1 optionalDependencies: - nodemailer: 8.0.7 + nodemailer: 9.0.1 next-safe-action@8.1.10(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: @@ -22405,7 +22405,7 @@ snapshots: node-releases@2.0.45: {} - nodemailer@8.0.7: {} + nodemailer@9.0.1: {} nopt@5.0.0: dependencies: