Compare commits

..

47 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 842d72eb69 fix: use Valkey for bundled Helm Redis 2026-05-21 18:33:04 +05:30
Dhruwang Jariwala 6183ab4744 fix: backport #8101 reserved contact keys and segment errors to 5.0 (#8103) 2026-05-21 17:12:03 +05:30
Dhruwang Jariwala 431a3d8a76 fix: allow enterprise oauth display names (#8100) 2026-05-21 16:30:16 +05:30
Dhruwang Jariwala 91ab958379 fix: chart date range type switch + presets include today (backport #8096) (#8097) 2026-05-21 16:26:59 +05:30
Dhruwang Jariwala e5df832653 fix: [Backport] adds close button on response error screen (#8098) 2026-05-21 16:26:09 +05:30
Johannes 0909c38eb1 code rabbit comments
(cherry picked from commit 8fb2287ae7)
2026-05-21 12:46:03 +02:00
Johannes 08b0d95295 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994)
Block creation of reserved safe-identifier contact keys across API, SDK, and CSV flows to prevent collisions ahead of the v5.1 migration, and surface clearer personal-link/segment validation errors instead of unknown failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 9da9f1582c)
2026-05-21 12:46:03 +02:00
pandeymangg a49e989413 refactor: replace custom functions with date-fns 2026-05-21 15:57:30 +05:30
Cursor Agent 9445b2f482 fix: restrict user name whitespace 2026-05-21 10:22:24 +00:00
Cursor Agent f07d832516 test: anonymize oauth display name fixture 2026-05-21 10:22:24 +00:00
Cursor Agent e615c692a9 fix: allow enterprise oauth display names
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:22:24 +00:00
Dhruwang Jariwala a9a910d15c fix: AI translation rich-text editors stay empty (backport #8084) (#8091) 2026-05-21 12:09:33 +02:00
pandeymangg c425e7aff4 adds close button on response error screen 2026-05-21 15:18:35 +05:30
Dhruwang a83a54a24a fix: chart date range type switch + presets include today (ENG-1034, ENG-1035)
ENG-1034: in the chart editor, switching the date range type from Custom
back to Preset would leave Update chart disabled because the toggle only
updated local UI state and never propagated the change to the parent
config. hasConfigChanged compared two queries that still held the old
custom [Date, Date] range, so it stayed false. The new
handleDateRangeTypeChange seats the parent timeDimension.dateRange to
match the chosen type (preset string or [start, end] tuple) on toggle
so hasConfigChanged can re-evaluate.

ENG-1035: Cube v1.6.6's native "last 7 days" / "last 30 days" / "this
month" presets anchor to end-of-yesterday and exclude today, which
diverges from every other analytics tool (GA, Mixpanel, PostHog, ...).
Added expandPresetDateRanges that rewrites known preset strings to
explicit inclusive [YYYY-MM-DD, YYYY-MM-DD] tuples at the Cube boundary
only. Stored client queries keep the preset string so hasConfigChanged
stays consistent and saved charts round-trip back to the Preset UI.
Unknown preset strings pass through unchanged.
2026-05-21 14:54:57 +05:30
Dhruwang Jariwala f7890eaec3 fix: backport billing-only settings access to 5.0 (#8090) 2026-05-21 13:02:25 +05:30
Dhruwang Jariwala 8cd3187eff fix: backport settings back navigation to 5.0 (#8089) 2026-05-21 13:02:09 +05:30
Dhruwang Jariwala 83bccc7ded fix: backport Cube API secret Helm defaults to 5.0 (#8088) 2026-05-21 12:50:13 +05:30
Dhruwang Jariwala 00aa6d5247 fix: [Backport] backports removal of timestamps from client responses api (#8087) 2026-05-21 12:45:12 +05:30
Dhruwang Jariwala 0657c94ee5 fix: [Backport] excel injection backport (#8086) 2026-05-21 12:44:46 +05:30
Johannes a36cef2936 fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit c0bf2ab7cc)
2026-05-21 07:11:50 +00:00
Johannes 467af8b6ef fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit 13c9677edd)
2026-05-21 07:09:46 +00:00
Dhruwang Jariwala d0e057eac1 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (backport #8061) (#8085) 2026-05-21 12:30:59 +05:30
Bhagya Amarasinghe ef1f5a2b12 fix: wire Cube API secret into Helm defaults
(cherry picked from commit 0e65278af7)
2026-05-21 07:00:06 +00:00
pandeymangg 770041923f backports removal of timestamps from client responses api 2026-05-21 12:14:11 +05:30
pandeymangg 3e66ff25a1 backports excel injection fix 2026-05-21 12:08:05 +05:30
Dhruwang Jariwala c979909da9 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:00:28 +05:30
Dhruwang Jariwala 010d96ebcd fix: harden Helm env value rendering (backport #8070) (#8078) 2026-05-21 11:49:30 +05:30
Dhruwang Jariwala a0b3054f4a fix: [Backport] responseId client api fix (#8083) 2026-05-21 11:47:34 +05:30
Dhruwang Jariwala 02d3cd2af3 fix: update Helm chart default image tag (backport #8072) (#8081) 2026-05-21 11:17:40 +05:30
Dhruwang Jariwala 2ef4eb4345 fix: require Cube API secret in compose (backport #8071) (#8080) 2026-05-21 11:17:25 +05:30
Dhruwang Jariwala 093757b386 fix: render scheduled-plan-change description placeholders correctly (backport #8064) (#8077) 2026-05-21 11:17:10 +05:30
pandeymangg 64f8746940 responseId api fix 2026-05-21 11:13:08 +05:30
Dhruwang Jariwala 851616078a fix: gate AI chart generation on smartTools, not dataAnalysis (backport #8060) (#8076) 2026-05-21 11:01:47 +05:30
Dhruwang Jariwala 06d5313629 fix: route Manage Teams and integration OAuth callbacks to settings (backport #8059) (#8075) 2026-05-21 11:01:34 +05:30
Bhagya Amarasinghe 7834c21d39 fix: update Helm chart default image tag (#8072) 2026-05-21 10:58:09 +05:30
Bhagya Amarasinghe f98ca39035 fix: require Cube API secret in compose (#8071) 2026-05-21 10:57:57 +05:30
Bhagya Amarasinghe 48f928b1bf fix: harden Helm env value rendering (#8070) 2026-05-21 10:48:28 +05:30
Dhruwang Jariwala f5dfb4739c fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:53 +05:30
Anshuman Pandey 5a1fc01388 fix: [Backport] client environment api sdk fixes (#8074) 2026-05-21 09:16:53 +04:00
Dhruwang Jariwala 77a39c13fa fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:17 +05:30
Dhruwang Jariwala 5a12539c75 fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:45:43 +05:30
Dhruwang Jariwala 74e0fba757 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0] (#8069) 2026-05-21 10:25:56 +05:30
Dhruwang Jariwala d9c2756185 fix: sync chart Cube feedback schema (backport #8057 to release/5.0) (#8066) 2026-05-21 10:20:53 +05:30
Matti Nannt 88ad5c8625 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0]
doesContactExist checked contact existence by id only, allowing a caller
to bind a display in one workspace to a contact from a different workspace.
Add workspaceId filter and rename to doesContactExistInWorkspace.

Backport of #8048 to release/5.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:27:48 +02:00
Bhagya Amarasinghe 610beee7eb fix: sync chart Cube feedback schema
Backport of #8057 to release/5.0 (excludes test file changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:55:59 +05:30
Javi Aguilar db0e2bb105 fix: add CSAT and CES summary filter icons (backport #8056) (#8063) 2026-05-20 15:05:47 +02:00
Johannes 419ceef413 fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 14:38:23 +02:00
133 changed files with 1442 additions and 7627 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
-1
View File
@@ -5,7 +5,6 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App.tsx";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
throw new InvalidInputError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -1,11 +1,10 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
});
test("returns a bad request response when the response id is missing", async () => {
@@ -245,34 +245,6 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
+1 -41
View File
@@ -135,7 +135,7 @@ describe("withV3ApiWrapper", () => {
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
@@ -440,46 +440,6 @@ describe("withV3ApiWrapper", () => {
);
});
test("preserves machine-readable validation metadata from Zod issues", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.unknown().superRefine((_value, ctx) => {
ctx.addIssue({
code: "custom",
message: "Unsupported field 'extra'",
path: ["extra"],
params: { code: "unsupported_field" },
});
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: JSON.stringify({ extra: true }),
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "extra",
reason: "Unsupported field 'extra'",
code: "unsupported_field",
},
]);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+4 -15
View File
@@ -14,7 +14,6 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
isInvalidParamCode,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
@@ -71,21 +70,11 @@ function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
return "Not authenticated";
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => {
const params = "params" in issue && isPlainObject(issue.params) ? issue.params : {};
const code = isInvalidParamCode(params.code) ? params.code : undefined;
return {
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
...(code ? { code } : {}),
};
});
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
+13 -13
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
@@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
@@ -39,7 +39,7 @@ describe("requireSessionWorkspaceAccess", () => {
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getWorkspace).not.toHaveBeenCalled();
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
@@ -55,11 +55,11 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getWorkspace).not.toHaveBeenCalled();
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
});
test("returns 403 when workspace is not found (avoid leaking existence)", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"ws_nonexistent",
@@ -72,12 +72,12 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
@@ -102,7 +102,7 @@ describe("requireSessionWorkspaceAccess", () => {
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
@@ -144,7 +144,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k");
});
@@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
@@ -179,7 +179,7 @@ describe("requireV3WorkspaceAccess", () => {
workspaceId: "proj_k",
organizationId: "org_k",
});
expect(getWorkspace).toHaveBeenCalledWith("proj_k");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k");
});
test("returns context for API key with write on workspace", async () => {
@@ -239,7 +239,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
const auth = {
...keyBase,
workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)],
+2 -35
View File
@@ -1,7 +1,5 @@
import { describe, expect, test } from "vitest";
import {
createdResponse,
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -15,7 +13,7 @@ import {
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y", identifier: "canonical-x" }],
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
@@ -23,7 +21,7 @@ describe("v3 problem responses", () => {
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y", identifier: "canonical-x" }]);
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
@@ -120,34 +118,3 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("createdResponse", () => {
test("returns 201 with Location, request id, and data envelope", async () => {
const res = createdResponse(
{ id: "survey_1" },
{
location: "/api/v3/surveys/survey_1",
requestId: "req-created",
}
);
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toBe("/api/v3/surveys/survey_1");
expect(res.headers.get("X-Request-Id")).toBe("req-created");
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
+1 -79
View File
@@ -6,45 +6,7 @@
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export const INVALID_PARAM_CODES = [
"dangling_reference",
"duplicate_identifier",
"duplicate_locale",
"forbidden_identifier",
"immutable_identifier",
"invalid_locale",
"invalid_reference",
"missing_required_field",
"missing_translation",
"unsupported_field",
] as const;
export type InvalidParamCode = (typeof INVALID_PARAM_CODES)[number];
const INVALID_PARAM_CODE_SET = new Set<InvalidParamCode>(INVALID_PARAM_CODES);
export function isInvalidParamCode(value: unknown): value is InvalidParamCode {
return typeof value === "string" && INVALID_PARAM_CODE_SET.has(value as InvalidParamCode);
}
export type InvalidParam = {
name: string;
reason: string;
code?: InvalidParamCode;
identifier?: string;
referenceType?:
| "block"
| "element"
| "ending"
| "hiddenField"
| "language"
| "variable"
| "variableName"
| "recall";
missingId?: string;
firstUsedAt?: string;
conflictsWith?: string;
};
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
@@ -209,43 +171,3 @@ export function successResponse<T>(
}
);
}
export function createdResponse<T>(
data: T,
options: { location: string; requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options.cache ?? CACHE_NO_STORE,
Location: options.location,
};
if (options.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: 201,
headers,
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -1,34 +1,45 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns workspaceId and organizationId when workspace exists", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("ws_abc");
expect(result).toEqual({
workspaceId: "ws_abc",
organizationId: "org_123",
});
expect(getWorkspace).toHaveBeenCalledWith("ws_abc");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc");
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_abc");
});
test("resolves legacy environmentId to canonical workspaceId", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_canonical" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_456");
const result = await resolveV3WorkspaceContext("env_legacy");
expect(result).toEqual({
workspaceId: "ws_canonical",
organizationId: "org_456",
});
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_canonical");
});
test("throws when workspace does not exist", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
});
});
+6 -5
View File
@@ -6,7 +6,7 @@
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
/**
* Internal IDs derived from a V3 workspace identifier.
@@ -19,20 +19,21 @@ export type V3WorkspaceContext = {
};
/**
* Resolves a V3 API workspaceId to internal workspaceId and organizationId.
* Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId.
*
* @throws ResourceNotFoundError if the workspace does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
const workspace = await getWorkspace(workspaceId);
const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId);
if (!workspace) {
throw new ResourceNotFoundError("workspace", workspaceId);
}
const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id);
const canonicalId = workspace.id;
const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId);
return {
workspaceId: workspace.id,
workspaceId: canonicalId,
organizationId,
};
}
@@ -0,0 +1,318 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+28 -122
View File
@@ -2,141 +2,42 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { getAuthorizedV3Survey } from "../authorization";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
...(error.normalizedCode && { identifier: error.normalizedCode }),
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: surveyParamsSchema,
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, authResult, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "readWrite",
requestId,
instance,
});
const survey = await getSurvey(surveyId);
if (response) {
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
@@ -145,9 +46,14 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
await deleteSurvey(surveyId);
const deletedSurvey = await deleteSurvey(surveyId);
return noContentResponse({ requestId });
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -1,71 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { getAuthorizedV3Survey } from "./authorization";
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
const survey = {
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
};
const surveyRecord = survey as unknown as NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
describe("getAuthorizedV3Survey", () => {
test("returns a generic forbidden response when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_1",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response?.status).toBe(403);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns the authorization response when workspace access is denied", async () => {
const forbiddenResponse = new Response(null, { status: 403 });
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(forbiddenResponse);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "readWrite",
requestId: "req_2",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response).toBe(forbiddenResponse);
});
test("returns the survey and authorization context when access is allowed", async () => {
const authResult = { workspaceId: survey.workspaceId, organizationId: "org_1" };
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_3",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result).toEqual({
survey,
authResult,
response: null,
});
});
});
@@ -1,37 +0,0 @@
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden } from "@/app/api/v3/lib/response";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getSurvey } from "@/lib/survey/service";
export async function getAuthorizedV3Survey(params: {
surveyId: string;
authentication: TV3Authentication;
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
-255
View File
@@ -1,255 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/survey/service", () => ({
createSurvey: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/permission", () => ({
getExternalUrlsPermission: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
const workspaceId = "clxx1234567890123456789012";
const rawCreateBody = {
workspaceId,
name: "Product Feedback",
defaultLanguage: "en-US",
metadata: {
cx_operation: "enterprise_onboarding",
title: { "en-US": "Product Feedback", "de-DE": "Produktfeedback" },
},
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
required: true,
},
],
},
],
};
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
const createdSurvey = {
id: "clsv1234567890123456789012",
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
languages: [],
questions: [],
welcomeCard: { enabled: false },
blocks: createBody.blocks,
endings: [],
hiddenFields: { enabled: false },
variables: [],
} as unknown as TSurvey;
type TLanguageUpsertArgs = Parameters<typeof prisma.language.upsert>[0];
type TLanguageUpsertReturn = ReturnType<typeof prisma.language.upsert>;
describe("createV3Survey", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.language.upsert).mockImplementation(
(args: TLanguageUpsertArgs): TLanguageUpsertReturn => {
const workspaceIdCode = args.where.workspaceId_code;
if (!workspaceIdCode) {
throw new Error("Expected workspaceId_code upsert selector");
}
return Promise.resolve({
id: `cllang${workspaceIdCode.code.toLowerCase().replaceAll("-", "")}`,
code: workspaceIdCode.code,
alias: null,
workspaceId: workspaceIdCode.workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
}) as TLanguageUpsertReturn;
}
);
vi.mocked(createSurvey).mockResolvedValue(createdSurvey);
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({
id: "org_1",
name: "Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
limits: { monthly: { responses: 1000 }, workspaces: 1 },
stripeCustomerId: null,
usageCycleAnchor: null,
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: undefined,
});
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
});
test("maps the public v3 body to the internal create payload", async () => {
await createV3Survey(
createBody,
{
user: { id: "user_1", email: "user@example.com", name: "User" },
expires: "2026-05-01",
},
"req_1"
);
expect(prisma.language.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { workspaceId_code: { workspaceId, code: "en-US" } },
create: { workspaceId, code: "en-US", alias: null },
})
);
expect(prisma.language.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { workspaceId_code: { workspaceId, code: "de-DE" } },
create: { workspaceId, code: "de-DE", alias: null },
})
);
expect(createSurvey).toHaveBeenCalledWith(
workspaceId,
expect.objectContaining({
name: "Product Feedback",
type: "link",
status: "draft",
createdBy: "user_1",
questions: [],
metadata: expect.objectContaining({
cx_operation: "enterprise_onboarding",
title: { default: "Product Feedback", "de-DE": "Produktfeedback" },
}),
blocks: [
expect.objectContaining({
elements: [
expect.objectContaining({
headline: {
default: "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
}),
],
}),
],
languages: [
expect.objectContaining({ default: true, enabled: true }),
expect.objectContaining({ default: false, enabled: true }),
],
})
);
expect(getOrganizationByWorkspaceId).not.toHaveBeenCalled();
expect(getExternalUrlsPermission).not.toHaveBeenCalled();
});
test("keeps createdBy null for API key calls and honors explicit disabled languages", async () => {
const body = ZV3CreateSurveyBody.parse({
...rawCreateBody,
languages: [{ code: "fr-FR", enabled: false }],
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
headline: {
...rawCreateBody.blocks[0].elements[0].headline,
"fr-FR": "Que devons-nous améliorer ?",
},
},
],
},
],
});
await createV3Survey(
body,
{
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
},
"req_2"
);
expect(createSurvey).toHaveBeenCalledWith(
workspaceId,
expect.objectContaining({
createdBy: null,
languages: expect.arrayContaining([
expect.objectContaining({ language: expect.objectContaining({ code: "fr-FR" }), enabled: false }),
]),
})
);
});
test("rejects external CTA buttons when the organization does not have external URL permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const body = ZV3CreateSurveyBody.parse({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
id: "external_cta",
type: "cta",
headline: { "en-US": "Continue" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { "en-US": "Open" },
},
],
},
],
});
await expect(createV3Survey(body, null, "req_3")).rejects.toThrow(V3SurveyCreatePermissionError);
expect(createSurvey).not.toHaveBeenCalled();
});
});
-106
View File
@@ -1,106 +0,0 @@
import "server-only";
import type { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { type TV3SurveyLanguageRequest, ensureV3WorkspaceLanguages } from "./languages";
import { prepareV3SurveyCreate } from "./prepare";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import type { TV3CreateSurveyBody } from "./schemas";
export class V3SurveyCreatePermissionError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyCreatePermissionError";
}
}
function getCreatedBy(authentication: TV3Authentication): string | null {
if (authentication && "user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
return null;
}
function hasExternalUrlReferences(input: TV3CreateSurveyBody): boolean {
const hasExternalEndingLink = input.endings.some(
(ending) => ending.type === "endScreen" && Boolean(ending.buttonLink)
);
const hasExternalCtaButton = getElementsFromBlocks(input.blocks).some(
(element) => element.type === "cta" && element.buttonExternal
);
return hasExternalEndingLink || hasExternalCtaButton;
}
async function assertV3SurveyCreatePermissions(
input: TV3CreateSurveyBody,
organizationId?: string
): Promise<void> {
if (!hasExternalUrlReferences(input)) {
return;
}
const resolvedOrganizationId =
organizationId ?? (await getOrganizationByWorkspaceId(input.workspaceId))?.id ?? null;
if (!resolvedOrganizationId) {
return;
}
const isExternalUrlsAllowed = await getExternalUrlsPermission(resolvedOrganizationId);
if (!isExternalUrlsAllowed) {
throw new V3SurveyCreatePermissionError(
"External URLs are not enabled for this organization. Upgrade to use external survey links."
);
}
}
export async function executeV3SurveyCreate(params: {
input: TV3CreateSurveyBody;
authentication: TV3Authentication;
languageRequests: TV3SurveyLanguageRequest[];
requestId?: string;
}) {
const { input, authentication, languageRequests, requestId } = params;
const languages = await ensureV3WorkspaceLanguages(input.workspaceId, languageRequests, requestId);
const surveyCreateInput: TSurveyCreateInput = {
name: input.name,
type: "link",
status: input.status,
metadata: input.metadata,
welcomeCard: input.welcomeCard,
blocks: input.blocks,
endings: input.endings,
hiddenFields: input.hiddenFields,
variables: input.variables,
languages,
questions: [],
createdBy: getCreatedBy(authentication),
};
return await createSurvey(input.workspaceId, surveyCreateInput);
}
export async function createV3Survey(
input: TV3CreateSurveyBody,
authentication: TV3Authentication,
requestId?: string,
organizationId?: string
) {
const preparation = prepareV3SurveyCreate(input);
if (!preparation.ok) {
throw new V3SurveyReferenceValidationError(preparation.validation.invalidParams);
}
await assertV3SurveyCreatePermissions(input, organizationId);
return await executeV3SurveyCreate({
input: preparation.document,
authentication,
languageRequests: preparation.languageRequests,
requestId,
});
}
@@ -1,120 +0,0 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["zh_hans_cn", "zh-Hans-CN"],
["ZH-hant-tw", "zh-Hant-TW"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
test("returns null for language-only tags", () => {
expect(normalizeV3SurveyLanguageTag("de")).toBeNull();
});
test("returns null for script-only tags without a region", () => {
expect(normalizeV3SurveyLanguageTag("zh_Hans")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us, zh_hans_cn")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US", "zh-Hans-CN"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
test("rejects language-only selectors", () => {
expect(parseV3SurveyLanguageQuery("de")).toEqual({
ok: false,
message: "Language 'de' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("matches configured script-region languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("ZH_hans_cn", [{ code: "zh-Hans-CN", enabled: true }])).toEqual({
ok: true,
code: "zh-Hans-CN",
});
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr-FR", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("ZH_hant_tw", languages)).toEqual({
ok: false,
reason: "unknown",
normalizedCode: "zh-Hant-TW",
message: "Language 'zh-Hant-TW' is not configured for this survey",
});
});
test("rejects language-only tags for surveys with a matching configured language", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({
ok: false,
reason: "invalid",
message: "Language 'de' is not a valid locale code",
});
});
test("resolves the implicit default locale for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en-US", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
-134
View File
@@ -1,134 +0,0 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
export type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown"; message: string; normalizedCode?: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
const V3_SURVEY_LOCALE_CODE_REGEX = /^[a-z]{2}(?:-[A-Z][a-z]{3})?-[A-Z]{2}$/;
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
const normalizedLanguage = Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
if (!normalizedLanguage || !V3_SURVEY_LOCALE_CODE_REGEX.test(normalizedLanguage)) {
return null;
}
return normalizedLanguage;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
return {
ok: false,
reason: "unknown",
normalizedCode: normalizedRequestedLanguage,
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
export function getV3SurveyLanguages(
survey: Pick<TInternalSurvey, "languages">,
fallbackLanguage: string
): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: fallbackLanguage, default: true, enabled: true }];
}
return languages;
}
export function getV3SurveyDefaultLanguage(
survey: Pick<TInternalSurvey, "languages">,
fallbackLanguage: string
): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: fallbackLanguage;
}
@@ -1,55 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { deriveV3SurveyLanguageRequests } from "./languages";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
upsert: vi.fn(),
},
},
}));
describe("deriveV3SurveyLanguageRequests", () => {
test("derives languages from survey content and known translatable metadata fields only", () => {
const document = ZV3CreateSurveyBody.parse({
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
defaultLanguage: "en-US",
metadata: {
title: {
"en-US": "Feedback",
"de-DE": "Feedback",
},
cx_context: {
"fr-FR": "Arbitrary customer metadata, not translatable survey text",
},
},
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: {
"en-US": "What should we improve?",
"pt-BR": "O que devemos melhorar?",
},
required: true,
},
],
},
],
});
expect(deriveV3SurveyLanguageRequests(document)).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "pt-BR", default: false, enabled: true },
]);
});
});
-159
View File
@@ -1,159 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import type { TI18nString } from "@formbricks/types/i18n";
import type { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { normalizeV3SurveyLanguageTag } from "./language";
import type { TV3SurveyDocument } from "./schemas";
export type TV3SurveyLanguageRequest = {
code: string;
default: boolean;
enabled: boolean;
};
const languageSelect = {
id: true,
code: true,
alias: true,
workspaceId: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.LanguageSelect;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isInternalI18nString(value: unknown): value is TI18nString {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function collectI18nLanguageCodes(value: unknown, languageCodes: Set<string>): void {
if (Array.isArray(value)) {
value.forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
return;
}
if (!isPlainObject(value)) {
return;
}
if (isInternalI18nString(value)) {
Object.keys(value).forEach((languageCode) => {
if (languageCode !== "default") {
const normalizedLanguageCode = normalizeV3SurveyLanguageTag(languageCode);
if (normalizedLanguageCode) {
languageCodes.add(normalizedLanguageCode);
}
}
});
return;
}
Object.values(value).forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
}
function collectMetadataI18nLanguageCodes(
metadata: TV3SurveyDocument["metadata"],
languageCodes: Set<string>
): void {
if (!isPlainObject(metadata)) {
return;
}
collectI18nLanguageCodes(metadata.title, languageCodes);
collectI18nLanguageCodes(metadata.description, languageCodes);
}
export function deriveV3SurveyLanguageRequests(input: TV3SurveyDocument): TV3SurveyLanguageRequest[] {
const requestedLanguages = new Map<string, TV3SurveyLanguageRequest>();
const addLanguage = (code: string, enabled = true): void => {
requestedLanguages.set(code, {
code,
default: code.toLowerCase() === input.defaultLanguage.toLowerCase(),
enabled: code.toLowerCase() === input.defaultLanguage.toLowerCase() ? true : enabled,
});
};
addLanguage(input.defaultLanguage);
input.languages.forEach((language) => {
addLanguage(language.code, language.enabled);
});
const contentLanguageCodes = new Set<string>();
collectI18nLanguageCodes(input.welcomeCard, contentLanguageCodes);
collectI18nLanguageCodes(input.blocks, contentLanguageCodes);
collectI18nLanguageCodes(input.endings, contentLanguageCodes);
collectMetadataI18nLanguageCodes(input.metadata, contentLanguageCodes);
contentLanguageCodes.forEach((languageCode) => {
if (!requestedLanguages.has(languageCode)) {
addLanguage(languageCode);
}
});
return Array.from(requestedLanguages.values()).sort((left, right) => {
if (left.default) return -1;
if (right.default) return 1;
return left.code.localeCompare(right.code);
});
}
export async function ensureV3WorkspaceLanguages(
workspaceId: string,
languageRequests: TV3SurveyLanguageRequest[],
requestId?: string
): Promise<TSurveyLanguage[]> {
const log = logger.withContext({ requestId, workspaceId });
try {
const languages = await Promise.all(
languageRequests.map((languageRequest) =>
prisma.language.upsert({
where: {
workspaceId_code: {
workspaceId,
code: languageRequest.code,
},
},
update: {},
create: {
workspaceId,
code: languageRequest.code,
alias: null,
},
select: languageSelect,
})
)
);
const languageByCode = new Map(languages.map((language) => [language.code.toLowerCase(), language]));
return languageRequests.map((languageRequest) => {
const language = languageByCode.get(languageRequest.code.toLowerCase());
if (!language) {
throw new DatabaseError(`Failed to resolve language '${languageRequest.code}'`);
}
return {
language,
default: languageRequest.default,
enabled: languageRequest.enabled,
};
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
log.error({ error }, "Error creating workspace languages for v3 survey write");
throw new DatabaseError(error.message);
}
throw error;
}
}
-259
View File
@@ -1,259 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { prepareV3SurveyCreate, prepareV3SurveyCreateInput, prepareV3SurveyPatchInput } from "./prepare";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
const workspaceId = "clxx1234567890123456789012";
const rawCreateBody = {
workspaceId,
name: "Product Feedback",
defaultLanguage: "en-US",
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
required: true,
},
],
},
],
};
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
const survey = {
id: "clsv1234567890123456789012",
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
languages: [
{
language: {
id: "cllangenus000000000000000",
code: "en-US",
alias: null,
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
},
default: true,
enabled: true,
},
],
questions: [],
welcomeCard: { enabled: false },
blocks: createBody.blocks,
endings: [],
hiddenFields: { enabled: false },
variables: [],
} as unknown as TSurvey;
describe("v3 survey preparation", () => {
test("prepares a valid create document and derives language side effects", () => {
const preparation = prepareV3SurveyCreate(createBody);
expect(preparation.ok).toBe(true);
if (!preparation.ok) {
throw new Error("Expected create preparation to succeed");
}
expect(preparation.languageRequests).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
]);
});
test("returns validation results instead of throwing for invalid create input", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
buttonUrl: "https://example.com",
},
],
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.buttonUrl",
code: "unsupported_field",
}),
])
);
}
});
test("rejects configured languages that are missing from translatable survey content", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
languages: [{ code: "pt-PT", enabled: true }],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline",
code: "missing_translation",
identifier: "pt-PT",
referenceType: "language",
}),
])
);
}
});
test("rejects partial derived translations before internal survey validation", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
subheader: { "en-US": "Tell us more" },
},
],
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.subheader",
code: "missing_translation",
identifier: "de-DE",
referenceType: "language",
}),
])
);
}
});
test("returns language and reference validation issues together", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
languages: [{ code: "pt-PT", enabled: true }],
blocks: [
{
...rawCreateBody.blocks[0],
logicFallback: "clmiss12345678901234567890",
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline",
code: "missing_translation",
}),
expect.objectContaining({
name: "blocks.0.logicFallback",
code: "dangling_reference",
}),
])
);
}
});
test("applies a patch over the current document before validating references", () => {
const preparation = prepareV3SurveyPatchInput(survey, {
blocks: [
{
...rawCreateBody.blocks[0],
logicFallback: "clmiss12345678901234567890",
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "blocks.0.logicFallback" })])
);
}
});
test("rejects patch input with immutable fields as validation results", () => {
const preparation = prepareV3SurveyPatchInput(survey, {
workspaceId,
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "workspaceId",
code: "unsupported_field",
}),
])
);
}
});
test("rejects non-draft element id changes on non-draft surveys", () => {
const preparation = prepareV3SurveyPatchInput(
{
...survey,
status: "inProgress",
} as TSurvey,
{
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
id: "renamed_satisfaction",
},
],
},
],
}
);
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.id",
reason: expect.stringContaining("cannot be changed"),
code: "immutable_identifier",
identifier: "satisfaction",
referenceType: "element",
}),
])
);
}
});
});
-181
View File
@@ -1,181 +0,0 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { InvalidParam } from "@/app/api/v3/lib/response";
import { getV3SurveyDefaultLanguage, getV3SurveyLanguages } from "./language";
import { type TV3SurveyLanguageRequest, deriveV3SurveyLanguageRequests } from "./languages";
import {
DEFAULT_V3_SURVEY_LANGUAGE,
type TV3CreateSurveyBody,
type TV3PatchSurveyBody,
type TV3SurveyDocument,
ZV3CreateSurveyBody,
ZV3SurveyDocumentBase,
createZV3PatchSurveyBodySchema,
formatV3ZodInvalidParams,
} from "./schemas";
import { type TV3SurveyDocumentValidationResult, validateV3SurveyDocument } from "./validation";
type TV3SurveyPrepareSuccess<TDocument> = {
ok: true;
document: TDocument;
validation: Extract<TV3SurveyDocumentValidationResult, { valid: true }>;
languageRequests: TV3SurveyLanguageRequest[];
};
type TV3SurveyPrepareFailure = {
ok: false;
validation: Extract<TV3SurveyDocumentValidationResult, { valid: false }>;
};
export type TV3SurveyPrepareResult<TDocument> = TV3SurveyPrepareSuccess<TDocument> | TV3SurveyPrepareFailure;
function invalidPreparation(invalidParams: InvalidParam[]): TV3SurveyPrepareFailure {
return {
ok: false,
validation: {
valid: false,
invalidParams,
},
};
}
function validPreparation<TDocument extends TV3SurveyDocument>(
document: TDocument
): TV3SurveyPrepareResult<TDocument> {
const validation = validateV3SurveyDocument(document);
if (!validation.valid) {
return invalidPreparation(validation.invalidParams);
}
return {
ok: true,
document,
validation,
languageRequests: deriveV3SurveyLanguageRequests(document),
};
}
function buildDocumentFromSurvey(survey: TInternalSurvey): TV3SurveyPrepareResult<TV3SurveyDocument> {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
return invalidPreparation([
{
name: "survey",
reason: "Legacy question-based surveys are not supported by the v3 survey management API",
},
]);
}
const documentResult = ZV3SurveyDocumentBase.safeParse({
name: survey.name,
status: survey.status,
metadata: survey.metadata ?? {},
defaultLanguage: getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE),
languages: getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE),
welcomeCard: survey.welcomeCard,
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
if (!documentResult.success) {
return invalidPreparation(formatV3ZodInvalidParams(documentResult.error, "survey"));
}
return validPreparation(documentResult.data);
}
function mergeV3SurveyPatch(document: TV3SurveyDocument, patch: TV3PatchSurveyBody): TV3SurveyDocument {
return {
...document,
...Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)),
};
}
function getElementIds(document: TV3SurveyDocument): Set<string> {
return new Set(document.blocks.flatMap((block) => block.elements.map((element) => element.id)));
}
function getImmutableElementIdIssues(
currentDocument: TV3SurveyDocument,
patchedDocument: TV3SurveyDocument
): InvalidParam[] {
if (currentDocument.status === "draft") {
return [];
}
const patchedElementIds = getElementIds(patchedDocument);
const issues: InvalidParam[] = [];
currentDocument.blocks.forEach((currentBlock) => {
const patchedBlockIndex = patchedDocument.blocks.findIndex((block) => block.id === currentBlock.id);
if (patchedBlockIndex === -1) {
return;
}
const patchedBlock = patchedDocument.blocks[patchedBlockIndex];
currentBlock.elements.forEach((currentElement, elementIndex) => {
if (currentElement.isDraft || patchedElementIds.has(currentElement.id)) {
return;
}
const patchedElement = patchedBlock.elements[elementIndex];
if (!patchedElement || patchedElement.id === currentElement.id) {
return;
}
issues.push({
name: `blocks.${patchedBlockIndex}.elements.${elementIndex}.id`,
reason: `Element id '${currentElement.id}' cannot be changed because the survey and element are no longer drafts`,
code: "immutable_identifier",
identifier: currentElement.id,
referenceType: "element",
});
});
});
return issues;
}
export function prepareV3SurveyCreate(
document: TV3CreateSurveyBody
): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
return validPreparation(document);
}
export function prepareV3SurveyCreateInput(input: unknown): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
const parsed = ZV3CreateSurveyBody.safeParse(input);
if (!parsed.success) {
return invalidPreparation(formatV3ZodInvalidParams(parsed.error, "data"));
}
return prepareV3SurveyCreate(parsed.data);
}
export function prepareV3SurveyPatchInput(
survey: TInternalSurvey,
input: unknown
): TV3SurveyPrepareResult<TV3SurveyDocument> {
const currentDocument = buildDocumentFromSurvey(survey);
if (!currentDocument.ok) {
return currentDocument;
}
const parsedPatch = createZV3PatchSurveyBodySchema(currentDocument.document.defaultLanguage).safeParse(
input
);
if (!parsedPatch.success) {
return invalidPreparation(formatV3ZodInvalidParams(parsedPatch.error, "data"));
}
const patchedDocument = mergeV3SurveyPatch(currentDocument.document, parsedPatch.data);
const immutableElementIdIssues = getImmutableElementIdIssues(currentDocument.document, patchedDocument);
if (immutableElementIdIssues.length > 0) {
return invalidPreparation(immutableElementIdIssues);
}
return validPreparation(patchedDocument);
}
@@ -1,373 +0,0 @@
import { describe, expect, test } from "vitest";
import { validateV3SurveyReferences } from "./reference-validation";
import { ZV3CreateSurveyBody } from "./schemas";
const validSurvey = ZV3CreateSurveyBody.parse({
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
hiddenFields: {
enabled: true,
fieldIds: ["account_id"],
},
variables: [
{
id: "clvar123456789012345678901",
name: "score",
type: "number",
value: 0,
},
],
endings: [
{
id: "clend123456789012345678901",
type: "endScreen",
headline: { "en-US": "Thanks" },
},
],
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
logicFallback: "clend123456789012345678901",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?" },
required: true,
},
],
logic: [
{
id: "cllog123456789012345678901",
conditions: {
id: "clgrp123456789012345678901",
connector: "and",
conditions: [
{
id: "clcon123456789012345678901",
leftOperand: { type: "element", value: "satisfaction" },
operator: "isSubmitted",
},
],
},
actions: [
{
id: "clact123456789012345678901",
objective: "calculate",
variableId: "clvar123456789012345678901",
operator: "add",
value: { type: "static", value: 1 },
},
],
},
],
},
],
});
describe("validateV3SurveyReferences", () => {
test("accepts a survey with consistent stable identifiers", () => {
expect(
validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
variables: validSurvey.variables,
})
).toEqual({ ok: true, invalidParams: [] });
});
test("rejects duplicate block, element, variable, and hidden field identifiers", () => {
const survey = {
...validSurvey,
hiddenFields: { enabled: true, fieldIds: ["account_id", "account_id"] },
variables: [
...validSurvey.variables,
{
id: "clvar123456789012345678901",
name: "score",
type: "number" as const,
value: 0,
},
],
blocks: [
...validSurvey.blocks,
{
...validSurvey.blocks[0],
elements: [{ ...validSurvey.blocks[0].elements[0] }],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "blocks.1.id" }),
expect.objectContaining({ name: "blocks.1.elements.0.id" }),
expect.objectContaining({ name: "variables.1.id" }),
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
expect.objectContaining({
name: "blocks.1.id",
code: "duplicate_identifier",
identifier: "clbk1234567890123456789012",
referenceType: "block",
firstUsedAt: "blocks.0.id",
}),
])
);
}
});
test("rejects cross-namespace identifier collisions", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: { enabled: true, fieldIds: ["account_id", "satisfaction"] },
variables: [
{
id: "satisfaction",
name: "account_id",
type: "number",
value: 0,
},
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
expect.objectContaining({ name: "variables.0.id" }),
expect.objectContaining({ name: "variables.0.name" }),
expect.objectContaining({
name: "hiddenFields.fieldIds.1",
code: "duplicate_identifier",
identifier: "satisfaction",
referenceType: "hiddenField",
conflictsWith: "blocks.0.elements.0.id",
}),
])
);
}
});
test("reports dangling logic references with actionable paths", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
logicFallback: "clmiss12345678901234567890",
logic: [
{
...validSurvey.blocks[0].logic![0],
actions: [
{
...validSurvey.blocks[0].logic![0].actions[0],
variableId: "clmiss12345678901234567890",
},
{
id: "cljmp123456789012345678901",
objective: "jumpToBlock" as const,
target: "clmiss12345678901234567890",
},
],
},
],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "blocks.0.logicFallback" }),
expect.objectContaining({ name: "blocks.0.logic.0.actions.0.variableId" }),
expect.objectContaining({ name: "blocks.0.logic.0.actions.1.target" }),
expect.objectContaining({
name: "blocks.0.logic.0.actions.0.variableId",
code: "dangling_reference",
missingId: "clmiss12345678901234567890",
referenceType: "variable",
}),
])
);
}
});
test("rejects logicFallback without logic before persistence", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
logic: undefined,
logicFallback: validSurvey.endings[0].id,
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.logicFallback",
code: "invalid_reference",
reason:
"logicFallback requires at least one logic rule on the same block; omit logicFallback for normal sequential flow or add blocks[].logic",
referenceType: "ending",
}),
])
);
}
});
test("rejects logicFallback targeting the same block", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
logicFallback: validSurvey.blocks[0].id,
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.logicFallback",
code: "invalid_reference",
reason: "logicFallback cannot target the same block",
}),
])
);
}
});
test("reports dangling recall references with actionable paths", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
elements: [
{
...validSurvey.blocks[0].elements[0],
headline: {
default: "Please explain #recall:missing_id/fallback:your answer#",
},
},
],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline.default",
reason: expect.stringContaining("missing_id"),
code: "dangling_reference",
missingId: "missing_id",
referenceType: "recall",
}),
])
);
}
});
test("reports dangling recall references in survey-level translatable fields", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
metadata: {
title: {
default: "Metadata #recall:missing_metadata_reference/fallback:value#",
},
},
variables: validSurvey.variables,
welcomeCard: {
enabled: true,
headline: {
default: "Welcome #recall:missing_welcome_reference/fallback:there#",
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "welcomeCard.headline.default",
reason: expect.stringContaining("missing_welcome_reference"),
}),
expect.objectContaining({
name: "metadata.title.default",
reason: expect.stringContaining("missing_metadata_reference"),
}),
])
);
}
});
test("ignores recall-like strings in arbitrary metadata values", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
metadata: {
cx_operation: "Enterprise #recall:external_context/fallback:context#",
},
variables: validSurvey.variables,
});
expect(result).toEqual({ ok: true, invalidParams: [] });
});
});
@@ -1,427 +0,0 @@
import type { TSurveyBlocks } from "@formbricks/types/surveys/blocks";
import type { TConditionGroup, TDynamicLogicFieldValue } from "@formbricks/types/surveys/logic";
import type { TSurveyEndings, TSurveyHiddenFields, TSurveyVariables } from "@formbricks/types/surveys/types";
import type { InvalidParam } from "@/app/api/v3/lib/response";
type TReferenceValidationInput = {
blocks: TSurveyBlocks;
endings: TSurveyEndings;
hiddenFields: TSurveyHiddenFields;
metadata?: unknown;
variables: TSurveyVariables;
welcomeCard?: unknown;
};
type TNamedReference = {
id: string;
path: string;
namespace: "block" | "element" | "ending" | "hiddenField" | "variable" | "variableName";
};
type TReferenceLookup = {
elementIds: Set<string>;
variableIds: Set<string>;
hiddenFieldIds: Set<string>;
};
export class V3SurveyReferenceValidationError extends Error {
invalidParams: InvalidParam[];
constructor(invalidParams: InvalidParam[]) {
super("Survey contains invalid references");
this.name = "V3SurveyReferenceValidationError";
this.invalidParams = invalidParams;
}
}
export type TV3SurveyReferenceValidationResult =
| { ok: true; invalidParams: [] }
| { ok: false; invalidParams: InvalidParam[] };
function addDuplicateIdIssues(
entries: { id: string; path: string }[],
label: string,
referenceType: NonNullable<InvalidParam["referenceType"]>,
issues: InvalidParam[]
): void {
const firstPathById = new Map<string, string>();
entries.forEach(({ id, path }) => {
const firstPath = firstPathById.get(id);
if (firstPath !== undefined) {
issues.push({
name: path,
reason: `${label} id '${id}' is duplicated; first used at ${firstPath}`,
code: "duplicate_identifier",
identifier: id,
referenceType,
firstUsedAt: firstPath,
});
return;
}
firstPathById.set(id, path);
});
}
function addDuplicateValueIssues(
values: string[],
pathForIndex: (index: number) => string,
label: string,
referenceType: NonNullable<InvalidParam["referenceType"]>,
issues: InvalidParam[]
): void {
const firstIndexByValue = new Map<string, number>();
values.forEach((value, index) => {
const firstIndex = firstIndexByValue.get(value);
if (firstIndex !== undefined) {
issues.push({
name: pathForIndex(index),
reason: `${label} '${value}' is duplicated; first used at ${pathForIndex(firstIndex)}`,
code: "duplicate_identifier",
identifier: value,
referenceType,
firstUsedAt: pathForIndex(firstIndex),
});
return;
}
firstIndexByValue.set(value, index);
});
}
function addCrossNamespaceCollisionIssues(entries: TNamedReference[], issues: InvalidParam[]): void {
const firstEntryById = new Map<string, TNamedReference>();
entries.forEach((entry) => {
const lookupId = entry.id.toLowerCase();
const firstEntry = firstEntryById.get(lookupId);
if (!firstEntry) {
firstEntryById.set(lookupId, entry);
return;
}
if (firstEntry.namespace === entry.namespace) {
return;
}
issues.push({
name: entry.path,
reason: `${entry.namespace} identifier '${entry.id}' conflicts with ${firstEntry.namespace} identifier at ${firstEntry.path}`,
code: "duplicate_identifier",
identifier: entry.id,
referenceType: entry.namespace,
conflictsWith: firstEntry.path,
});
});
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function addRecallReferenceIssues(
value: unknown,
path: string,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
if (typeof value === "string") {
const recallPattern = /#recall:([A-Za-z0-9_-]+)/g;
for (const match of value.matchAll(recallPattern)) {
const recallId = match[1];
const isKnownReference =
references.elementIds.has(recallId) ||
references.variableIds.has(recallId) ||
references.hiddenFieldIds.has(recallId);
if (!isKnownReference) {
issues.push({
name: path,
reason: `Recall reference '${recallId}' is not defined in blocks, variables, or hiddenFields.fieldIds`,
code: "dangling_reference",
identifier: recallId,
referenceType: "recall",
missingId: recallId,
});
}
}
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) => addRecallReferenceIssues(entry, `${path}.${index}`, references, issues));
return;
}
if (!isPlainObject(value)) {
return;
}
Object.entries(value).forEach(([key, entry]) => {
addRecallReferenceIssues(entry, path ? `${path}.${key}` : key, references, issues);
});
}
function addMetadataRecallReferenceIssues(
metadata: unknown,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
if (!isPlainObject(metadata)) {
return;
}
addRecallReferenceIssues(metadata.title, "metadata.title", references, issues);
addRecallReferenceIssues(metadata.description, "metadata.description", references, issues);
}
function validateDynamicOperand(
operand: TDynamicLogicFieldValue,
path: string,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
if (operand.type === "element" && !references.elementIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Element id '${operand.value}' is not defined in blocks`,
code: "dangling_reference",
identifier: operand.value,
referenceType: "element",
missingId: operand.value,
});
}
if (operand.type === "variable" && !references.variableIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Variable id '${operand.value}' is not defined in variables`,
code: "dangling_reference",
identifier: operand.value,
referenceType: "variable",
missingId: operand.value,
});
}
if (operand.type === "hiddenField" && !references.hiddenFieldIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Hidden field id '${operand.value}' is not defined in hiddenFields.fieldIds`,
code: "dangling_reference",
identifier: operand.value,
referenceType: "hiddenField",
missingId: operand.value,
});
}
}
function validateConditionGroup(
conditionGroup: TConditionGroup,
path: string,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
conditionGroup.conditions.forEach((condition, index) => {
const conditionPath = `${path}.conditions.${index}`;
if ("conditions" in condition) {
validateConditionGroup(condition, conditionPath, references, issues);
return;
}
validateDynamicOperand(condition.leftOperand, `${conditionPath}.leftOperand`, references, issues);
if (condition.rightOperand?.type && condition.rightOperand.type !== "static") {
validateDynamicOperand(condition.rightOperand, `${conditionPath}.rightOperand`, references, issues);
}
});
}
export function getV3SurveyReferenceInvalidParams(input: TReferenceValidationInput): InvalidParam[] {
const issues: InvalidParam[] = [];
const blockIds = input.blocks.map((block) => block.id);
const blockEntries = input.blocks.map((block, index) => ({
id: block.id,
path: `blocks.${index}.id`,
}));
const endingIds = input.endings.map((ending) => ending.id);
const endingEntries = input.endings.map((ending, index) => ({
id: ending.id,
path: `endings.${index}.id`,
}));
const elementEntries = input.blocks.flatMap((block, blockIndex) =>
block.elements.map((element, elementIndex) => ({
id: element.id,
path: `blocks.${blockIndex}.elements.${elementIndex}.id`,
}))
);
const elementIds = elementEntries.map((element) => element.id);
const hiddenFieldIds = input.hiddenFields.fieldIds ?? [];
const hiddenFieldEntries = hiddenFieldIds.map((id, index) => ({
id,
path: `hiddenFields.fieldIds.${index}`,
}));
const variableIds = input.variables.map((variable) => variable.id);
const variableIdEntries = variableIds.map((id, index) => ({
id,
path: `variables.${index}.id`,
}));
const variableNames = input.variables.map((variable) => variable.name);
const variableNameEntries = variableNames.map((id, index) => ({
id,
path: `variables.${index}.name`,
}));
const navigationTargetIds = new Set([...blockIds, ...endingIds]);
const navigationTargetReferenceTypes = new Map<string, "block" | "ending">([
...blockIds.map((id) => [id, "block"] as const),
...endingIds.map((id) => [id, "ending"] as const),
]);
const references = {
elementIds: new Set(elementIds),
variableIds: new Set(variableIds),
hiddenFieldIds: new Set(hiddenFieldIds),
};
addDuplicateIdIssues(blockEntries, "Block", "block", issues);
addDuplicateIdIssues(elementEntries, "Element", "element", issues);
addDuplicateIdIssues(variableIdEntries, "Variable", "variable", issues);
addDuplicateValueIssues(
hiddenFieldIds,
(index) => `hiddenFields.fieldIds.${index}`,
"Hidden field id",
"hiddenField",
issues
);
addDuplicateValueIssues(
variableNames,
(index) => `variables.${index}.name`,
"Variable name",
"variableName",
issues
);
addCrossNamespaceCollisionIssues(
[
...blockEntries.map((entry) => ({ ...entry, namespace: "block" as const })),
...elementEntries.map((entry) => ({ ...entry, namespace: "element" as const })),
...endingEntries.map((entry) => ({ ...entry, namespace: "ending" as const })),
...hiddenFieldEntries.map((entry) => ({ ...entry, namespace: "hiddenField" as const })),
...variableIdEntries.map((entry) => ({ ...entry, namespace: "variable" as const })),
...variableNameEntries.map((entry) => ({ ...entry, namespace: "variableName" as const })),
],
issues
);
input.blocks.forEach((block, blockIndex) => {
if (block.logicFallback && !block.logic?.length) {
issues.push({
name: `blocks.${blockIndex}.logicFallback`,
reason:
"logicFallback requires at least one logic rule on the same block; omit logicFallback for normal sequential flow or add blocks[].logic",
code: "invalid_reference",
identifier: block.logicFallback,
referenceType: navigationTargetReferenceTypes.get(block.logicFallback) ?? "block",
});
}
if (block.logicFallback && block.logicFallback === block.id) {
issues.push({
name: `blocks.${blockIndex}.logicFallback`,
reason: "logicFallback cannot target the same block",
code: "invalid_reference",
identifier: block.logicFallback,
referenceType: "block",
});
}
if (block.logicFallback && !navigationTargetIds.has(block.logicFallback)) {
issues.push({
name: `blocks.${blockIndex}.logicFallback`,
reason: `Logic fallback target '${block.logicFallback}' is not defined in blocks or endings`,
code: "dangling_reference",
identifier: block.logicFallback,
referenceType: "block",
missingId: block.logicFallback,
});
}
block.logic?.forEach((logic, logicIndex) => {
const logicPath = `blocks.${blockIndex}.logic.${logicIndex}`;
validateConditionGroup(logic.conditions, `${logicPath}.conditions`, references, issues);
logic.actions.forEach((action, actionIndex) => {
const actionPath = `${logicPath}.actions.${actionIndex}`;
if (action.objective === "calculate") {
if (!references.variableIds.has(action.variableId)) {
issues.push({
name: `${actionPath}.variableId`,
reason: `Variable id '${action.variableId}' is not defined in variables`,
code: "dangling_reference",
identifier: action.variableId,
referenceType: "variable",
missingId: action.variableId,
});
}
if (action.value.type !== "static") {
validateDynamicOperand(action.value, `${actionPath}.value`, references, issues);
}
}
if (action.objective === "requireAnswer" && !references.elementIds.has(action.target)) {
issues.push({
name: `${actionPath}.target`,
reason: `Element id '${action.target}' is not defined in blocks`,
code: "dangling_reference",
identifier: action.target,
referenceType: "element",
missingId: action.target,
});
}
if (action.objective === "jumpToBlock" && !navigationTargetIds.has(action.target)) {
issues.push({
name: `${actionPath}.target`,
reason: `Jump target '${action.target}' is not defined in blocks or endings`,
code: "dangling_reference",
identifier: action.target,
referenceType: "block",
missingId: action.target,
});
}
});
});
});
addRecallReferenceIssues(input.blocks, "blocks", references, issues);
addRecallReferenceIssues(input.endings, "endings", references, issues);
addRecallReferenceIssues(input.welcomeCard, "welcomeCard", references, issues);
addMetadataRecallReferenceIssues(input.metadata, references, issues);
return issues;
}
export function validateV3SurveyReferences(
input: TReferenceValidationInput
): TV3SurveyReferenceValidationResult {
const invalidParams = getV3SurveyReferenceInvalidParams(input);
if (invalidParams.length > 0) {
return { ok: false, invalidParams };
}
return { ok: true, invalidParams: [] };
}
export function assertValidV3SurveyReferences(input: TReferenceValidationInput): void {
const result = validateV3SurveyReferences(input);
if (!result.ok) {
throw new V3SurveyReferenceValidationError(result.invalidParams);
}
}
@@ -50,10 +50,6 @@ vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
};
});
vi.mock("./create", () => ({
createV3Survey: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
+2 -88
View File
@@ -1,5 +1,5 @@
/**
* /api/v3/surveys — list and create block-based survey management resources.
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
@@ -7,7 +7,6 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
createdResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -15,15 +14,8 @@ import {
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import { ZV3CreateSurveyBody } from "./schemas";
import {
V3SurveyUnsupportedShapeError,
serializeV3SurveyListItem,
serializeV3SurveyResource,
} from "./serializers";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
@@ -88,81 +80,3 @@ export const GET = withV3ApiWrapper({
}
},
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZV3CreateSurveyBody,
},
action: "created",
targetType: "survey",
handler: async ({ authentication, auditLog, parsedInput, requestId, instance }) => {
const { body } = parsedInput;
const log = logger.withContext({ requestId, workspaceId: body.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
body.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const survey = await createV3Survey(
{
...body,
workspaceId: authResult.workspaceId,
},
authentication,
requestId,
authResult.organizationId
);
const resource = serializeV3SurveyResource(survey);
if (auditLog) {
auditLog.organizationId = authResult.organizationId;
auditLog.targetId = survey.id;
auditLog.newObject = resource;
}
return createdResponse(resource, {
requestId,
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);
}
},
});
-597
View File
@@ -1,597 +0,0 @@
import { describe, expect, test } from "vitest";
import {
ZV3CreateSurveyBody,
ZV3PatchSurveyBody,
createZV3PatchSurveyBodySchema,
formatV3ZodInvalidParams,
} from "./schemas";
const validCreateBody = {
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?" },
required: true,
},
],
},
],
};
describe("ZV3CreateSurveyBody", () => {
test("accepts a valid block-based create body and applies public defaults", () => {
const parsed = ZV3CreateSurveyBody.parse(validCreateBody);
expect(parsed).toMatchObject({
workspaceId: validCreateBody.workspaceId,
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
defaultLanguage: "en-US",
languages: [],
welcomeCard: { enabled: false },
endings: [],
hiddenFields: { enabled: false },
variables: [],
});
expect(parsed.blocks[0].elements[0]).toMatchObject({
headline: { default: "What should we improve?" },
});
});
test("generates server-managed block and variable ids on create when omitted", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
blocks: [
{
name: "Generated ID Block",
elements: validCreateBody.blocks[0].elements,
},
],
variables: [
{
name: "score",
type: "number",
value: 0,
},
],
});
expect(parsed.blocks[0].id).toEqual(expect.any(String));
expect(parsed.blocks[0].id.length).toBeGreaterThan(0);
expect(parsed.variables[0].id).toEqual(expect.any(String));
expect(parsed.variables[0].id.length).toBeGreaterThan(0);
});
test("normalizes locale maps and language codes before shared survey validation", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
defaultLanguage: "en_us",
languages: [{ code: "de_de" }],
welcomeCard: {
enabled: true,
headline: { en_us: "Welcome", de_de: "Willkommen" },
},
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { en_us: "Hello", de_de: "Hallo" },
},
],
},
],
});
expect(parsed.defaultLanguage).toBe("en-US");
expect(parsed.languages).toEqual([{ code: "de-DE", enabled: true }]);
expect(parsed.welcomeCard).toMatchObject({
headline: { default: "Welcome", "de-DE": "Willkommen" },
});
expect(parsed.blocks[0].elements[0]).toMatchObject({
headline: { default: "Hello", "de-DE": "Hallo" },
});
});
test("rejects an invalid defaultLanguage instead of silently defaulting", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
defaultLanguage: "not a locale",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "defaultLanguage",
reason: "Language 'not a locale' is not a valid locale code",
code: "invalid_locale",
}),
])
);
}
});
test("rejects duplicate locale keys after normalization", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { "en-US": "Hello", en_us: "Duplicate" },
},
],
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline.en_us",
reason: "Language key 'en_us' duplicates 'en-US' after locale normalization",
code: "duplicate_locale",
}),
])
);
}
});
test("rejects unsupported top-level fields instead of silently ignoring them", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
questions: [],
styling: {},
createdBy: "user_1",
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["questions", "styling", "createdBy"])
);
});
test("rejects unsupported nested fields instead of stripping them", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
targeting: {},
elements: [
{
...validCreateBody.blocks[0].elements[0],
analytics: {},
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["blocks.0.targeting", "blocks.0.elements.0.analytics"])
);
});
test("rejects element fields that do not belong to the selected element type", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
buttonUrl: "https://example.com",
scale: "star",
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
"blocks.0.elements.0.buttonUrl"
);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain("blocks.0.elements.0.scale");
expect(
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.buttonUrl")
).toMatchObject({
message: expect.stringContaining("element type 'openText'"),
});
});
test("rejects choice fields that do not belong to the selected element type", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
id: "choices",
type: "multipleChoiceSingle",
headline: { "en-US": "Pick one" },
required: true,
choices: [
{ id: "choice_1", label: { "en-US": "A" }, imageUrl: "https://example.com/a.png" },
{ id: "choice_2", label: { "en-US": "B" } },
],
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
"blocks.0.elements.0.choices.0.imageUrl"
);
expect(
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.choices.0.imageUrl")
).toMatchObject({
message: expect.stringContaining("Allowed fields: id, label"),
});
});
test("does not rewrite locale-shaped objects in logic metadata", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
},
],
logic: [
{
id: "cllog123456789012345678901",
conditions: {
id: "clgrp123456789012345678901",
connector: "and",
conditions: [
{
id: "clcon123456789012345678901",
leftOperand: {
type: "element",
value: "satisfaction",
meta: { "en-US": "metadata" },
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: "clact123456789012345678901",
objective: "requireAnswer",
target: "satisfaction",
},
],
},
],
},
],
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("Expected schema validation to pass");
}
expect(result.data.blocks[0].logic?.[0].conditions.conditions[0]).toMatchObject({
leftOperand: {
meta: { "en-US": "metadata" },
},
});
});
test("rejects the internal default translation key in public v3 input", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { default: "Internal key should not be public" },
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path.join(".")).toBe("blocks.0.elements.0.headline.default");
});
test("preserves arbitrary metadata while normalizing known translatable metadata fields", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
metadata: {
cx_context: {
"de-DE": "This is arbitrary customer metadata, not translation content",
},
title: {
"en-US": "Feedback Survey",
"de-DE": "Feedback-Umfrage",
},
},
});
expect(parsed.metadata).toMatchObject({
cx_context: {
"de-DE": "This is arbitrary customer metadata, not translation content",
},
title: {
default: "Feedback Survey",
"de-DE": "Feedback-Umfrage",
},
});
});
test("rejects non-link survey types for this survey-template endpoint", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
type: "app",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(["type"]);
});
test("rejects malformed locale maps that do not include the default language", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { "not a locale": "Hello" },
},
],
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline.not a locale",
reason: "Language key 'not a locale' is not a valid locale code",
code: "invalid_locale",
}),
])
);
}
});
test("reports missing required element fields before shared element union errors", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
id: "feedback",
type: "openText",
headline: { "en-US": "Tell us more" },
},
],
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.required",
reason: "Missing required field 'required' for element type 'openText'",
code: "missing_required_field",
}),
])
);
}
});
test("reports missing required ending fields before shared ending union errors", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
endings: [
{
type: "endScreen",
headline: { "en-US": "Thanks!" },
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "endings.0.id",
reason: "Missing required field 'id' for ending type 'endScreen'",
code: "missing_required_field",
}),
])
);
}
});
test("reports missing ending type with a precise invalid param path", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
endings: [
{
id: "clend123456789012345678901",
headline: { "en-US": "Thanks!" },
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "endings.0.type",
reason: "Missing required field 'type' for survey ending",
code: "missing_required_field",
}),
])
);
}
});
test("rejects duplicate language entries and disabled default language", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
languages: [{ code: "en-US", enabled: false }, { code: "en_us" }],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "languages.0.enabled",
reason: "The default language cannot be disabled",
}),
expect.objectContaining({
name: "languages.1.code",
reason: "Language 'en-US' is duplicated",
code: "duplicate_locale",
}),
])
);
}
});
test("reports invalid language entries with machine-readable locale metadata", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
languages: [{ code: "de", enabled: true }],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "languages.0.code",
reason: "Language 'de' is not a valid locale code",
code: "invalid_locale",
}),
])
);
}
});
});
describe("ZV3PatchSurveyBody", () => {
test("accepts a strict top-level partial and preserves omitted defaults", () => {
const parsed = ZV3PatchSurveyBody.parse({
name: "Updated survey",
});
expect(parsed).toEqual({ name: "Updated survey" });
});
test("rejects an empty patch body", () => {
const result = ZV3PatchSurveyBody.safeParse({});
expect(result.success).toBe(false);
expect(result.error?.issues[0]).toMatchObject({
message: "Request body must include at least one updatable field",
});
});
test("rejects immutable and out-of-scope fields", () => {
const result = ZV3PatchSurveyBody.safeParse({
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
type: "link",
questions: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["id", "workspaceId", "type", "questions"])
);
});
test("normalizes patch translation maps using the current default language", () => {
const parsed = createZV3PatchSurveyBodySchema("de-DE").parse({
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { de_de: "Hallo", en_us: "Hello" },
required: true,
},
],
},
],
});
expect(parsed.blocks?.[0].elements[0]).toMatchObject({
headline: { default: "Hallo", "en-US": "Hello" },
});
expect(parsed).not.toHaveProperty("defaultLanguage");
});
test("does not generate missing ids for canonical patch documents", () => {
const result = ZV3PatchSurveyBody.safeParse({
blocks: [
{
name: "Missing ID Block",
elements: validCreateBody.blocks[0].elements,
},
],
variables: [
{
name: "score",
type: "number",
value: 0,
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["blocks.0.id", "variables.0.id"])
);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,304 +0,0 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {
cx: "enterprise",
arbitraryConfig: { default: "preserve-me", mode: "strict" },
title: { default: "Product Feedback", "de-DE": "Produktfeedback" },
},
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
metadata: {
cx: "enterprise",
arbitraryConfig: { default: "preserve-me", mode: "strict" },
title: {
"en-US": "Product Feedback",
"de-DE": "Produktfeedback",
},
},
});
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en-US"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("filters script-region locale selectors while preserving maps", () => {
const survey = {
...baseSurvey,
languages: [
...baseSurvey.languages,
{
default: false,
enabled: true,
language: {
id: "lang_4",
code: "zh-Hans-CN",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", zh_hans_cn: "欢迎" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["ZH_hans_cn"] });
expect(resource).toMatchObject({
welcomeCard: { headline: { "zh-Hans-CN": "欢迎" } },
});
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr-FR"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de-DE"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects language-only selectors", () => {
expect(() => serializeV3SurveyResource(baseSurvey, { lang: ["de"] })).toThrow(
"Language 'de' is not a valid locale code"
);
});
test("exposes the normalized locale code for unknown language errors", () => {
try {
serializeV3SurveyResource(baseSurvey, { lang: ["ES_es"] });
} catch (error) {
if (!(error instanceof V3SurveyLanguageError)) {
throw error;
}
expect(error.message).toBe("Language 'es-ES' is not configured for this survey");
expect(error.normalizedCode).toBe("es-ES");
return;
}
throw new Error("Expected V3SurveyLanguageError");
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+3 -193
View File
@@ -1,203 +1,13 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import {
type TV3SurveyLanguage,
getV3SurveyDefaultLanguage,
getV3SurveyLanguages,
normalizeV3SurveyLanguageTag,
resolveV3SurveyLanguageCode,
} from "./language";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(
message: string,
readonly normalizedCode?: string
) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function serializeMetadata(
metadata: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (!isPlainObject(metadata)) {
return metadata as TSerializedValue;
}
const serializedMetadata: Record<string, TSerializedValue> = { ...metadata } as Record<
string,
TSerializedValue
>;
for (const key of ["title", "description"]) {
if (metadata[key] !== undefined) {
serializedMetadata[key] = serializeCanonicalValue(
metadata[key],
defaultLanguage,
languageCodes,
options
);
}
}
return serializedMetadata;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message, result.normalizedCode);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE);
const languages = getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: serializeMetadata(survey.metadata, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
}),
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
@@ -1,108 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getAuthorizedV3Survey } from "../authorization";
import {
type TV3SurveyPrepareResult,
prepareV3SurveyCreateInput,
prepareV3SurveyPatchInput,
} from "../prepare";
import { type TV3SurveyDocument, ZV3EmptyQuery, ZV3SurveyValidationRequestBody } from "../schemas";
const createWorkspaceSchema = z.object({
workspaceId: z.cuid2(),
});
function serializeValidationResult<TDocument extends TV3SurveyDocument>(
operation: "create" | "patch",
preparation: TV3SurveyPrepareResult<TDocument>
) {
if (!preparation.ok) {
return {
valid: false,
operation,
invalid_params: preparation.validation.invalidParams,
};
}
return {
valid: true,
operation,
invalid_params: [],
languages: preparation.languageRequests.map((languageRequest) => ({
...languageRequest,
writeBehavior: "connect_or_create" as const,
})),
};
}
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZV3SurveyValidationRequestBody,
query: ZV3EmptyQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const { body } = parsedInput;
const log = logger.withContext({ requestId, operation: body.operation });
try {
if (body.operation === "create") {
const workspaceResult = createWorkspaceSchema.safeParse(body.data);
if (workspaceResult.success) {
const authResult = await requireV3WorkspaceAccess(
authentication,
workspaceResult.data.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
}
return successResponse(serializeValidationResult("create", prepareV3SurveyCreateInput(body.data)), {
requestId,
cache: "private, no-store",
});
}
const { survey, response } = await getAuthorizedV3Survey({
surveyId: body.surveyId,
authentication,
access: "readWrite",
requestId,
instance,
});
if (response) {
log.warn(
{ statusCode: response.status, surveyId: body.surveyId },
"Survey not found or not accessible"
);
return response;
}
return successResponse(
serializeValidationResult("patch", prepareV3SurveyPatchInput(survey, body.data)),
{
requestId,
cache: "private, no-store",
}
);
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey validation unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
-145
View File
@@ -1,145 +0,0 @@
import type { InvalidParam } from "@/app/api/v3/lib/response";
import { validateV3SurveyReferences } from "./reference-validation";
import type { TV3SurveyDocument } from "./schemas";
export type TV3SurveyDocumentValidationResult =
| { valid: true; invalidParams: [] }
| { valid: false; invalidParams: InvalidParam[] };
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isInternalI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getConfiguredTranslationLanguageCodes(document: TV3SurveyDocument): string[] {
const defaultLanguage = document.defaultLanguage.toLowerCase();
const languageCodes = new Set<string>();
document.languages.forEach((language) => {
const code = language.code;
if (code.toLowerCase() !== defaultLanguage) {
languageCodes.add(code);
}
});
return Array.from(languageCodes.values());
}
function collectTranslationLanguageCodes(value: unknown, languageCodes: Set<string>): void {
if (Array.isArray(value)) {
value.forEach((entry) => collectTranslationLanguageCodes(entry, languageCodes));
return;
}
if (!isPlainObject(value)) {
return;
}
if (isInternalI18nString(value)) {
Object.keys(value).forEach((languageCode) => {
if (languageCode !== "default") {
languageCodes.add(languageCode);
}
});
return;
}
Object.values(value).forEach((entry) => collectTranslationLanguageCodes(entry, languageCodes));
}
function getRequiredTranslationLanguageCodes(document: TV3SurveyDocument): string[] {
const languageCodes = new Set(getConfiguredTranslationLanguageCodes(document));
collectTranslationLanguageCodes(document.welcomeCard, languageCodes);
collectTranslationLanguageCodes(document.blocks, languageCodes);
collectTranslationLanguageCodes(document.endings, languageCodes);
return Array.from(languageCodes.values());
}
function addMissingTranslationIssues(
value: unknown,
path: string,
languageCodes: string[],
issues: InvalidParam[]
): void {
if (languageCodes.length === 0) {
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) =>
addMissingTranslationIssues(entry, path ? `${path}.${index}` : String(index), languageCodes, issues)
);
return;
}
if (!isPlainObject(value)) {
return;
}
if (isInternalI18nString(value)) {
languageCodes.forEach((languageCode) => {
if (value[languageCode] === undefined) {
issues.push({
name: path,
reason: `Translatable field is missing configured language '${languageCode}'`,
code: "missing_translation",
identifier: languageCode,
referenceType: "language",
missingId: languageCode,
});
}
});
return;
}
Object.entries(value).forEach(([key, entry]) =>
addMissingTranslationIssues(entry, path ? `${path}.${key}` : key, languageCodes, issues)
);
}
function getV3SurveyLanguageInvalidParams(document: TV3SurveyDocument): InvalidParam[] {
const languageCodes = getRequiredTranslationLanguageCodes(document);
const issues: InvalidParam[] = [];
addMissingTranslationIssues(document.welcomeCard, "welcomeCard", languageCodes, issues);
addMissingTranslationIssues(document.blocks, "blocks", languageCodes, issues);
addMissingTranslationIssues(document.endings, "endings", languageCodes, issues);
return issues;
}
export function validateV3SurveyDocument(document: TV3SurveyDocument): TV3SurveyDocumentValidationResult {
const languageInvalidParams = getV3SurveyLanguageInvalidParams(document);
const invalidParams = [...languageInvalidParams];
const referenceValidation = validateV3SurveyReferences({
blocks: document.blocks,
endings: document.endings,
hiddenFields: document.hiddenFields,
metadata: document.metadata,
variables: document.variables,
welcomeCard: document.welcomeCard,
});
if (!referenceValidation.ok) {
invalidParams.push(...referenceValidation.invalidParams);
}
if (invalidParams.length > 0) {
return {
valid: false,
invalidParams,
};
}
return { valid: true, invalidParams: [] };
}
+2
View File
@@ -1859,6 +1859,7 @@ checksums:
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
@@ -1893,6 +1894,7 @@ checksums:
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
+1 -2
View File
@@ -1,6 +1,5 @@
import "server-only";
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -213,7 +212,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
-103
View File
@@ -733,85 +733,6 @@ describe("Tests for createSurvey", () => {
})
);
});
test("creates survey languages from validated language inputs", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
languages: [
{
default: true,
enabled: true,
language: {
id: "cllang12345678901234567890",
code: "en-US",
alias: null,
workspaceId: mockWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
languages: {
create: [
{
language: {
connect: {
id: "cllang12345678901234567890",
},
},
default: true,
enabled: true,
},
],
},
}),
})
);
});
test("preserves an explicitly provided segment relation for existing callers", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
segment: {
id: "clseg123456789012345678901",
title: "Segment",
description: null,
isPrivate: false,
filters: [],
workspaceId: mockWorkspaceId,
surveys: [],
createdAt: new Date(),
updatedAt: new Date(),
},
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
segment: {
connect: {
id: "clseg123456789012345678901",
},
},
}),
})
);
});
});
describe("Sad Path", () => {
@@ -824,30 +745,6 @@ describe("Tests for createSurvey", () => {
);
});
test("rejects survey languages from a different workspace", async () => {
await expect(
createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
languages: [
{
default: true,
enabled: true,
language: {
id: "cllang12345678901234567890",
code: "en-US",
alias: null,
workspaceId: "clotherworkspace0000000000",
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
})
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.survey.create).not.toHaveBeenCalled();
});
test("throws DatabaseError if there is a Prisma error", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
const mockError = new Prisma.PrismaClientKnownRequestError("Database error", {
+7 -30
View File
@@ -621,17 +621,6 @@ const validateSurveyCreateDataMedia = (
return data;
};
const assertSurveyLanguagesBelongToWorkspace = (
workspaceId: string,
languages: TSurveyCreateInput["languages"]
): void => {
for (const surveyLanguage of languages ?? []) {
if (surveyLanguage.language.workspaceId !== workspaceId) {
throw new ResourceNotFoundError("Language", surveyLanguage.language.id);
}
}
};
export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreateInput): Promise<TSurvey> => {
const [parsedWorkspaceId, parsedSurveyBody] = validateInputs(
[workspaceId, ZId],
@@ -639,24 +628,9 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
);
try {
const { createdBy, languages, segment, followUps, ...restSurveyBody } = parsedSurveyBody;
assertSurveyLanguagesBelongToWorkspace(parsedWorkspaceId, languages);
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const surveyLanguagesCreateData: Prisma.SurveyLanguageCreateNestedManyWithoutSurveyInput | undefined =
languages?.length
? {
create: languages.map((surveyLanguage) => ({
language: {
connect: {
id: surveyLanguage.language.id,
},
},
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
})),
}
: undefined;
const actionClasses = await getActionClasses(parsedWorkspaceId);
@@ -667,15 +641,18 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
publishOn: normalizedPublishOn,
status: restSurveyBody.status ?? "draft",
}),
languages: surveyLanguagesCreateData,
segment: segment?.id ? { connect: { id: segment.id } } : undefined,
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
const data = validateSurveyCreateDataMedia(
attachSurveyFollowUpsToCreateData(attachSurveyCreatorToCreateData(baseData, createdBy), followUps)
attachSurveyFollowUpsToCreateData(
attachSurveyCreatorToCreateData(baseData, createdBy),
restSurveyBody.followUps
)
);
const organization = await getOrganizationByWorkspaceId(parsedWorkspaceId);
@@ -38,50 +38,6 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -104,54 +60,4 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p }));
const buffer = convertToXlsxBuffer(["name"], rows);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
payloads.forEach((p, i) => {
const cell = sheet[`A${i + 2}`]; // row 1 is header
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${p}`);
});
});
test("should defang formula injection in xlsx header names", () => {
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const headerCell = sheet["A1"];
expect(headerCell.f).toBeUndefined();
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
+4 -20
View File
@@ -27,16 +27,8 @@ describe("validateInputs", () => {
expect(() => validateInputs([123, schema])).toThrow(ValidationError);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.any(z.ZodError),
issues: expect.arrayContaining([
expect.objectContaining({
message: "Invalid input: expected string, received number",
}),
]),
valuePreview: "123",
}),
"Input validation failed"
expect.anything(),
expect.stringContaining("Validation failed")
);
});
@@ -55,16 +47,8 @@ describe("validateInputs", () => {
expect(() => validateInputs(["valid", stringSchema], ["invalid", numberSchema])).toThrow(ValidationError);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.any(z.ZodError),
issues: expect.arrayContaining([
expect.objectContaining({
message: "Invalid input: expected number, received string",
}),
]),
valuePreview: '"invalid"',
}),
"Input validation failed"
expect.anything(),
expect.stringContaining("Validation failed")
);
});
});
+2 -6
View File
@@ -20,12 +20,8 @@ export function validateInputs<T extends ValidationPair<any>[]>(
.join("; ");
logger.error(
{
error: inputValidation.error,
issues: inputValidation.error.issues,
valuePreview: JSON.stringify(value).substring(0, 100),
},
"Input validation failed"
inputValidation.error,
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
);
throw new ValidationError(`Validation failed: ${zodDetails}`);
}
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",
"attribute_key_required": "Schlüssel ist erforderlich",
"attribute_key_reserved_future_default": "Der Schlüssel ist für zukünftige Standardattribute reserviert ({reservedKeys}). Bitte wähle einen anderen Schlüssel.",
"attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen",
"attribute_label": "Bezeichnung",
"attribute_label_placeholder": "z. B. Geburtsdatum",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Persönlichen Link erstellen",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu erstellen.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_csv_reserved_column_names": "Reservierte CSV-Spaltennamen: {columns}. Diese Namen sind für zukünftige Standardattribute ({reservedKeys}) reserviert und können nicht als neue Attribute erstellt werden.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_activity_yet": "Noch keine Aktivität",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
"attribute_key_required": "Key is required",
"attribute_key_reserved_future_default": "Key is reserved for future default attributes ({reservedKeys}). Please choose a different key.",
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
"attribute_label": "Label",
"attribute_label_placeholder": "e.g. Date of Birth",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
"invalid_csv_reserved_column_names": "Reserved CSV column name(s): {columns}. These names are reserved for future default attributes ({reservedKeys}) and cannot be created as new attributes.",
"invalid_date_format": "Invalid date format. Please use a valid date.",
"invalid_number_format": "Invalid number format. Please enter a valid number.",
"no_activity_yet": "No activity yet",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",
"attribute_key_required": "La clave es obligatoria",
"attribute_key_reserved_future_default": "La clave está reservada para atributos predeterminados futuros ({reservedKeys}). Por favor, elige una clave diferente.",
"attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generar enlace personal",
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
"invalid_csv_reserved_column_names": "Nombre(s) de columna CSV reservado(s): {columns}. Estos nombres están reservados para atributos predeterminados futuros ({reservedKeys}) y no se pueden crear como nuevos atributos.",
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
"no_activity_yet": "Aún no hay actividad",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",
"attribute_key_required": "La clé est requise",
"attribute_key_reserved_future_default": "La clé est réservée pour les attributs par défaut futurs ({reservedKeys}). Veuillez choisir une clé différente.",
"attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr: uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre",
"attribute_label": "Étiquette",
"attribute_label_placeholder": "ex. Date de naissance",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
"invalid_csv_reserved_column_names": "Nom(s) de colonne CSV réservé(s) : {columns}. Ces noms sont réservés pour les attributs par défaut futurs ({reservedKeys}) et ne peuvent pas être créés en tant que nouveaux attributs.",
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
"no_activity_yet": "Aucune activité pour le moment",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók. Betűvel kell kezdődnie.",
"attribute_key_placeholder": "például: szuletesi_ido",
"attribute_key_required": "A kulcs kötelező",
"attribute_key_reserved_future_default": "A kulcs le van foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}). Kérem, válasszon egy másik kulcsot.",
"attribute_key_safe_identifier_required": "A kulcs csak biztonságos azonosító lehet: csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók, és betűvel kell kezdődnie",
"attribute_label": "Címke",
"attribute_label_placeholder": "például: Születési idő",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Személyes hivatkozás előállítása",
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
"invalid_csv_reserved_column_names": "Fenntartott CSV oszlopnév/nevek: {columns}. Ezek a nevek le vannak foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}), és nem hozhatók létre új attribútumokként.",
"invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.",
"no_activity_yet": "Még nincs tevékenység",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth",
"attribute_key_required": "キーは必須です",
"attribute_key_reserved_future_default": "このキーは将来のデフォルト属性用に予約されています({reservedKeys})。別のキーを選択してください。",
"attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります",
"attribute_label": "ラベル",
"attribute_label_placeholder": "例: 生年月日",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
"invalid_csv_reserved_column_names": "予約されたCSV列名: {columns}。これらの名前は将来のデフォルト属性({reservedKeys})用に予約されており、新しい属性として作成できません。",
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
"no_activity_yet": "まだアクティビティがありません",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",
"attribute_key_required": "Sleutel is verplicht",
"attribute_key_reserved_future_default": "Sleutel is gereserveerd voor toekomstige standaardattributen ({reservedKeys}). Kies een andere sleutel.",
"attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter",
"attribute_label": "Label",
"attribute_label_placeholder": "bijv. Geboortedatum",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Persoonlijke link genereren",
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
"invalid_csv_reserved_column_names": "Gereserveerde CSV-kolomnaam/namen: {columns}. Deze namen zijn gereserveerd voor toekomstige standaardattributen ({reservedKeys}) en kunnen niet als nieuwe attributen worden aangemaakt.",
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
"no_activity_yet": "Nog geen activiteit",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolha uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex: Data de nascimento",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Esses nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
"no_activity_yet": "Nenhuma atividade ainda",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolhe uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex. Data de nascimento",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Estes nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
"no_activity_yet": "Ainda sem atividade",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",
"attribute_key_required": "Cheia este obligatorie",
"attribute_key_reserved_future_default": "Cheia este rezervată pentru atribute implicite viitoare ({reservedKeys}). Te rugăm să alegi o cheie diferită.",
"attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă",
"attribute_label": "Etichetă",
"attribute_label_placeholder": "ex: Data nașterii",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generează link personal",
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
"invalid_csv_reserved_column_names": "Nume de coloană CSV rezervate: {columns}. Aceste nume sunt rezervate pentru atribute implicite viitoare ({reservedKeys}) și nu pot fi create ca atribute noi.",
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
"no_activity_yet": "Nicio activitate încă",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth",
"attribute_key_required": "Ключ обязателен",
"attribute_key_reserved_future_default": "Ключ зарезервирован для будущих атрибутов по умолчанию ({reservedKeys}). Пожалуйста, выбери другой ключ.",
"attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы",
"attribute_label": "Метка",
"attribute_label_placeholder": "например, дата рождения",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Сгенерировать персональную ссылку",
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
"invalid_csv_reserved_column_names": "Зарезервированные названия столбцов CSV: {columns}. Эти названия зарезервированы для будущих атрибутов по умолчанию ({reservedKeys}) и не могут быть созданы как новые атрибуты.",
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
"no_activity_yet": "Пока нет активности",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",
"attribute_key_required": "Nyckel krävs",
"attribute_key_reserved_future_default": "Nyckeln är reserverad för framtida standardattribut ({reservedKeys}). Välj en annan nyckel.",
"attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav",
"attribute_label": "Etikett",
"attribute_label_placeholder": "t.ex. Födelsedatum",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generera personlig länk",
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
"invalid_csv_reserved_column_names": "Reserverade CSV-kolumnnamn: {columns}. Dessa namn är reserverade för framtida standardattribut ({reservedKeys}) och kan inte skapas som nya attribut.",
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
"no_activity_yet": "Ingen aktivitet än",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Yalnızca küçük harfler, rakamlar ve alt çizgiler. Bir harfle başlamalıdır.",
"attribute_key_placeholder": "örn. dogum_tarihi",
"attribute_key_required": "Anahtar gereklidir",
"attribute_key_reserved_future_default": "Anahtar, gelecekteki varsayılan özellikler için ayrılmıştır ({reservedKeys}). Lütfen farklı bir anahtar seçin.",
"attribute_key_safe_identifier_required": "Anahtar güvenli bir tanımlayıcı olmalıdır: yalnızca küçük harfler, rakamlar ve alt çizgiler içermeli ve bir harfle başlamalıdır",
"attribute_label": "Etiket",
"attribute_label_placeholder": "örn. Doğum Tarihi",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Kişisel Bağlantı Oluştur",
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir anket seç.",
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harf, rakam ve alt çizgi içerebilir ve bir harfle başlamalıdır.",
"invalid_csv_reserved_column_names": "Ayrılmış CSV sütun adı/adları: {columns}. Bu adlar gelecekteki varsayılan özellikler ({reservedKeys}) için ayrılmıştır ve yeni özellik olarak oluşturulamaz.",
"invalid_date_format": "Geçersiz tarih formatı. Lütfen geçerli bir tarih kullanın.",
"invalid_number_format": "Geçersiz sayı formatı. Lütfen geçerli bir sayı girin.",
"no_activity_yet": "Henüz aktivite yok",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "键为必填项",
"attribute_key_reserved_future_default": "该键已保留用于未来的默认属性({reservedKeys})。请选择其他键。",
"attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头",
"attribute_label": "标签",
"attribute_label_placeholder": "例如:出生日期",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
"invalid_csv_reserved_column_names": "CSV 列名已被保留:{columns}。这些名称已保留用于未来的默认属性({reservedKeys}),无法创建为新属性。",
"invalid_date_format": "日期格式无效。请使用有效日期。",
"invalid_number_format": "数字格式无效。请输入有效的数字。",
"no_activity_yet": "暂无活动",
+2
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "金鑰為必填項目",
"attribute_key_reserved_future_default": "此鍵已保留供未來預設屬性使用({reservedKeys})。請選擇其他鍵。",
"attribute_key_safe_identifier_required": "金鑰必須為安全識別字:僅限小寫字母、數字和底線,且必須以字母開頭",
"attribute_label": "標籤",
"attribute_label_placeholder": "例如:出生日期",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "產生個人連結",
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
"invalid_csv_reserved_column_names": "保留的 CSV 欄位名稱:{columns}。這些名稱已保留供未來預設屬性使用({reservedKeys}),無法建立為新屬性。",
"invalid_date_format": "日期格式無效。請使用有效的日期。",
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
"no_activity_yet": "尚無活動",
@@ -10,6 +10,10 @@ import {
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceIds: string[], params: TGetContactAttributeKeysFilter) => {
@@ -45,6 +49,13 @@ export const createContactAttributeKey = async (
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { workspaceId, name, description, key, dataType } = contactAttributeKey;
if (isReservedFutureDefaultAttributeKey(key)) {
return err({
type: "bad_request",
details: [{ field: "key", issue: getReservedFutureDefaultAttributeKeyIssue([key]) }],
});
}
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
workspace: {
@@ -105,6 +105,28 @@ describe("createContactAttributeKey", () => {
}
});
test("returns bad request when key is reserved for future defaults", async () => {
const result = await createContactAttributeKey({
...inputContactAttributeKey,
key: "user_id",
});
expect(result.ok).toBe(false);
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "bad_request",
details: [
{
field: "key",
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
],
});
}
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
@@ -2,6 +2,10 @@ import { z } from "zod";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({})
.refine(
@@ -38,6 +42,14 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyInput",
@@ -65,6 +77,14 @@ export const ZContactAttributeKeyCreateInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyCreateInput",
@@ -1,96 +0,0 @@
import { describe, expect, test } from "vitest";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "./date-presets";
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
});
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
describe("expandPresetDateRanges", () => {
test("includes today for 'last 7 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
});
test("includes today for 'last 30 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
});
test("expands 'today' to today..today", () => {
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
});
test("expands 'yesterday' to yesterday..yesterday", () => {
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
});
test("'this month' runs from the 1st through today", () => {
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
});
test("'last month' is the full previous calendar month", () => {
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
});
test("'last month' handles year rollover", () => {
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
});
test("'this quarter' starts at the first day of the calendar quarter", () => {
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
});
test("'this year' starts on Jan 1", () => {
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
});
test("leaves explicit [start, end] tuple unchanged", () => {
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
});
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
});
test("returns input unchanged when there are no time dimensions", () => {
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
});
test("preserves other timeDimension fields (granularity, dimension)", () => {
const q: TChartQuery = {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
],
};
const result = expandPresetDateRanges(q, NOW);
expect(result.timeDimensions?.[0]).toMatchObject({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: ["2026-05-15", "2026-05-21"],
});
});
test("does not mutate the input query", () => {
const q = queryWithDateRange("last 7 days");
const before = JSON.stringify(q);
expandPresetDateRanges(q, NOW);
expect(JSON.stringify(q)).toBe(before);
});
});
@@ -1,5 +1,3 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -8,17 +6,6 @@ import {
getFilterOperatorsForType,
} from "./schema-definition";
const chartCubeSchemaPath = fileURLToPath(
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
);
const dockerCubeSchemaPath = fileURLToPath(
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
);
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
describe("schema-definition", () => {
describe("getFilterOperatorsForType", () => {
test("returns string operators", () => {
@@ -107,20 +94,5 @@ describe("schema-definition", () => {
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
test("only exposes members present in the deployed Cube schema", () => {
const chartCubeSchema = readChartCubeSchema();
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
getCubeMemberName(id)
);
for (const member of exposedMembers) {
expect(chartCubeSchema).toContain(` ${member}: {`);
}
});
test("keeps the Helm and Docker Cube schemas in sync", () => {
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
});
});
});
@@ -3,8 +3,12 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import {
TContactAttributeKeyUpdateInput,
ZContactAttributeKeyUpdateInput,
@@ -56,6 +60,10 @@ export const updateContactAttributeKey = async (
): Promise<TContactAttributeKey | null> => {
validateInputs([contactAttributeKeyId, ZId], [data, ZContactAttributeKeyUpdateInput]);
if (data.key && isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
try {
const contactAttributeKey = await prisma.contactAttributeKey.update({
where: {
@@ -1,12 +1,21 @@
import { z } from "zod";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const ZContactAttributeKeyCreateInput = z.object({
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
key: z
.string()
.refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
}),
description: z.string().optional(),
type: z.enum(["custom"]),
dataType: ZContactAttributeDataType.optional(),
@@ -24,6 +33,9 @@ export const ZContactAttributeKeyUpdateInput = z.object({
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
})
.optional(),
dataType: ZContactAttributeDataType.optional(),
});
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { createContactAttributeKey, getContactAttributeKeys } from "./contact-attribute-keys";
@@ -144,6 +144,17 @@ describe("createContactAttributeKey", () => {
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when key is reserved for future defaults", async () => {
await expect(
createContactAttributeKey(workspaceId, {
...createInput,
key: "user_id",
})
).rejects.toThrow(InvalidInputError);
expect(prisma.contactAttributeKey.count).not.toHaveBeenCalled();
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("should throw DatabaseError if Prisma create fails", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
const errorMessage = "Prisma create error";
@@ -3,10 +3,14 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceIds: string[]): Promise<TContactAttributeKey[]> => {
@@ -29,6 +33,10 @@ export const createContactAttributeKey = async (
workspaceId: string,
data: TContactAttributeKeyCreateInput
): Promise<TContactAttributeKey | null> => {
if (isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
where: {
workspaceId,
@@ -6,6 +6,10 @@ import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-k
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
@@ -545,6 +549,22 @@ export const upsertBulkContacts = async (
});
}
const reservedNewKeys = attributeKeys.filter(
(key) => !existingKeySet.has(key) && isReservedFutureDefaultAttributeKey(key)
);
if (reservedNewKeys.length > 0) {
return err({
type: "bad_request",
details: [
{
field: "attributes",
issue: getReservedFutureDefaultAttributeKeyIssue(reservedNewKeys),
},
],
});
}
// Type Detection Phase
const attributeValuesByKey = buildAttributeValuesByKey(contacts);
const attributeTypeMap = determineAttributeTypes(attributeValuesByKey, existingAttributeKeys);
@@ -347,6 +347,42 @@ describe("upsertBulkContacts", () => {
expect(prisma.$executeRaw).toHaveBeenCalled();
});
test("should return bad request when payload creates reserved future default keys", async () => {
const mockContacts = [
{
attributes: [
{ attributeKey: { key: "email", name: "Email" }, value: "john@example.com" },
{ attributeKey: { key: "user_id", name: "User Id" }, value: "user-123" },
],
},
];
const mockParsedEmails = ["john@example.com"];
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce([
{ id: "attr-key-email", key: "email", workspaceId: mockWorkspaceId, name: "Email" },
] as any);
const result = await upsertBulkContacts(mockContacts, mockWorkspaceId, mockParsedEmails);
expect(result.ok).toBe(false);
expect(prisma.contact.createMany).not.toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "bad_request",
details: [
{
field: "attributes",
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
],
});
}
});
test("should update attribute key names when they change", async () => {
// Mock data: a contact with an attribute that has a new name for an existing key
const mockContacts = [
@@ -10,6 +10,10 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import {
createContactAttributeKey,
deleteContactAttributeKey,
@@ -19,10 +23,15 @@ import {
const ZCreateContactAttributeKeyAction = z.object({
workspaceId: ZId,
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
key: z
.string()
.refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
}),
name: z.string().optional(),
description: z.string().optional(),
dataType: ZContactAttributeDataType.optional(),
@@ -8,6 +8,10 @@ import { useTranslation } from "react-i18next";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase, isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -93,6 +97,14 @@ export function CreateAttributeModal({ workspaceId }: Readonly<CreateAttributeMo
);
return false;
}
if (isReservedFutureDefaultAttributeKey(key)) {
setKeyError(
t("workspace.contacts.attribute_key_reserved_future_default", {
reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
})
);
return false;
}
setKeyError("");
return true;
};
@@ -10,33 +10,37 @@ export const CsvTable = ({ data }: CsvTableProps) => {
}
const columns = Object.keys(data[0]);
return (
<div className="w-full overflow-x-auto rounded-md">
<div
className="sticky top-0 z-10 grid gap-2 border-b-2 border-slate-100 bg-slate-100 px-3 py-2 text-left"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, index) => (
<span
key={index}
className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold capitalize leading-tight">
{header.replace(/_/g, " ")}
</span>
))}
</div>
{data.map((row, rowIndex) => (
<div
key={rowIndex}
className="grid gap-2 border-b border-gray-200 bg-white px-3 py-2 text-left leading-tight last:border-b-0"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, colIndex) => (
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-xs">
{row[header]}
</span>
<table className="w-max min-w-full border-separate border-spacing-0 text-left text-xs">
<thead>
<tr className="bg-slate-100">
{columns.map((header) => (
<th
key={header}
scope="col"
className="sticky top-0 z-10 min-w-[120px] border-b-2 border-slate-200 bg-slate-100 px-3 py-2 font-semibold">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="bg-white">
{columns.map((header) => (
<td
key={`${rowIndex}-${header}`}
className="min-w-[120px] border-b border-slate-200 px-3 py-2">
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
{row[header] ?? ""}
</span>
</td>
))}
</tr>
))}
</div>
))}
</tbody>
</table>
</div>
);
};
@@ -4,6 +4,10 @@ import { ChevronDownIcon } from "lucide-react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { Button } from "@/modules/ui/components/button";
import {
Command,
@@ -41,6 +45,8 @@ export const UploadContactsAttributeCombobox = ({
currentKey,
}: ITagsComboboxProps) => {
const { t } = useTranslation();
const normalizedSearchValue = searchValue.trim();
useEffect(() => {
// reset search value and value when closing the combobox
if (!open) {
@@ -50,20 +56,56 @@ export const UploadContactsAttributeCombobox = ({
// Check if the search value is a valid safe identifier for creating new attributes
const isValidNewKey = useMemo(() => {
if (!searchValue) return false;
return isSafeIdentifier(searchValue.trim());
}, [searchValue]);
if (!normalizedSearchValue) return false;
return isSafeIdentifier(normalizedSearchValue);
}, [normalizedSearchValue]);
const isReservedNewKey = useMemo(() => {
if (!normalizedSearchValue) return false;
return isReservedFutureDefaultAttributeKey(normalizedSearchValue);
}, [normalizedSearchValue]);
const existingKeyMatch = useMemo(() => {
return keys.find((tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase()));
}, [keys, searchValue]);
const handleCreateKey = () => {
if (isValidNewKey && !existingKeyMatch) {
createKey(searchValue.trim());
if (isValidNewKey && !existingKeyMatch && !isReservedNewKey) {
createKey(normalizedSearchValue);
}
};
const renderCreateOptionContent = () => {
if (isValidNewKey && !isReservedNewKey) {
return (
<button
onClick={handleCreateKey}
className="h-8 w-full text-left hover:cursor-pointer hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!!existingKeyMatch}>
+ {t("common.add")} {normalizedSearchValue}
</button>
);
}
if (isReservedNewKey) {
return (
<div className="flex flex-col py-1 text-xs text-slate-500">
<span className="text-red-500">
{t("workspace.contacts.attribute_key_reserved_future_default", {
reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
})}
</span>
</div>
);
}
return (
<div className="flex flex-col py-1 text-xs text-slate-500">
<span className="text-red-500">{t("workspace.contacts.attribute_key_safe_identifier_required")}</span>
</div>
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -135,22 +177,7 @@ export const UploadContactsAttributeCombobox = ({
);
})}
{searchValue !== "" && !keys.some((tag) => tag.label === searchValue) && (
<CommandItem value="_create">
{isValidNewKey ? (
<button
onClick={handleCreateKey}
className="h-8 w-full text-left hover:cursor-pointer hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!!existingKeyMatch}>
+ Add {searchValue}
</button>
) : (
<div className="flex flex-col py-1 text-xs text-slate-500">
<span className="text-red-500">
{t("workspace.contacts.attribute_key_safe_identifier_required")}
</span>
</div>
)}
</CommandItem>
<CommandItem value="_create">{renderCreateOptionContent()}</CommandItem>
)}
</CommandGroup>
</CommandList>
@@ -13,6 +13,10 @@ import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
import { CsvTable } from "@/modules/ee/contacts/components/csv-table";
import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -257,7 +261,6 @@ export const UploadContactsCSVButton = ({
useEffect(() => {
const matches: Record<string, string> = {};
const invalidColumns: string[] = [];
for (const columnName of csvColumns) {
let matched = false;
@@ -270,25 +273,66 @@ export const UploadContactsCSVButton = ({
}
if (!matched) {
// This column will become a new attribute - validate it's a safe identifier
if (!isSafeIdentifier(columnName)) {
invalidColumns.push(columnName);
}
matches[columnName] = columnName;
}
}
setAttributeMap(matches);
}, [contactAttributeKeys, csvColumns]);
useEffect(() => {
if (!csvColumns.length || !csvResponse.length) {
return;
}
const invalidColumns: string[] = [];
const reservedColumns: string[] = [];
for (const columnName of csvColumns) {
const mappedAttribute = attributeMap[columnName];
if (!mappedAttribute) {
continue;
}
const mapsToExistingAttribute = contactAttributeKeys.some(
(attributeKey) => attributeKey.id === mappedAttribute
);
if (mapsToExistingAttribute) {
continue;
}
if (!isSafeIdentifier(mappedAttribute)) {
invalidColumns.push(columnName);
continue;
}
if (isReservedFutureDefaultAttributeKey(mappedAttribute)) {
reservedColumns.push(columnName);
}
}
const errorMessages: string[] = [];
// Show error for invalid column names that would become new attributes
if (invalidColumns.length > 0) {
setError(
errorMessages.push(
t("workspace.contacts.invalid_csv_column_names", {
columns: invalidColumns.join(", "),
})
);
}
}, [contactAttributeKeys, csvColumns, t]);
if (reservedColumns.length > 0) {
errorMessages.push(
t("workspace.contacts.invalid_csv_reserved_column_names", {
columns: reservedColumns.join(", "),
reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
})
);
}
setError(errorMessages.join("\n"));
}, [attributeMap, contactAttributeKeys, csvColumns, csvResponse.length, t]);
useEffect(() => {
if (error && errorContainerRef.current) {
@@ -304,7 +348,7 @@ export const UploadContactsCSVButton = ({
const exampleData = [
{
email: "user1@example.com",
userId: "1001",
user_id: "1001",
first_name: "John",
last_name: "Doe",
age: "28",
@@ -313,7 +357,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user2@example.com",
userId: "1002",
user_id: "1002",
first_name: "Jane",
last_name: "Smith",
age: "34",
@@ -322,7 +366,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user3@example.com",
userId: "1003",
user_id: "1003",
first_name: "Mark",
last_name: "Jones",
age: "45",
@@ -331,7 +375,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user4@example.com",
userId: "1004",
user_id: "1004",
first_name: "Emily",
last_name: "Brown",
age: "22",
@@ -340,7 +384,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user5@example.com",
userId: "1005",
user_id: "1005",
first_name: "David",
last_name: "Wilson",
age: "31",
@@ -0,0 +1,39 @@
// Keep these keys reserved until the v5.1 migration moves default contact attributes
// from camelCase to safe identifiers with backward compatibility aliases.
// This is a preventive guardrail only (no schema/data migration in v5).
export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS = [
"user_id",
"first_name",
"last_name",
] as const;
export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT =
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS.join(", ");
export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE = `Key is reserved for the v5.1 safe-identifier default attribute migration. Reserved keys: ${RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT}.`;
const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEY_SET: ReadonlySet<string> = new Set(
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS
);
const normalizeKey = (key: string): string => key.trim().toLowerCase();
export const isReservedFutureDefaultAttributeKey = (key: string): boolean => {
return RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEY_SET.has(normalizeKey(key));
};
export const getReservedFutureDefaultAttributeKeys = (keys: string[]): string[] => {
const normalized = keys
.map(normalizeKey)
.filter((key) => key.length > 0 && isReservedFutureDefaultAttributeKey(key));
return Array.from(new Set(normalized));
};
export const getReservedFutureDefaultAttributeKeyIssue = (keys: string[]): string => {
const reservedKeys = getReservedFutureDefaultAttributeKeys(keys);
return `Reserved attribute key(s): ${reservedKeys.join(
", "
)}. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.`;
};
@@ -221,6 +221,30 @@ describe("updateAttributes", () => {
);
});
test("skips creating reserved future default attributes", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]);
vi.mocked(getContactAttributes).mockResolvedValue({ email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const result = await updateAttributes(contactId, userId, workspaceId, {
email: "john@example.com",
user_id: "future-safe",
});
expect(result.success).toBe(true);
expect(result.errors).toContainEqual({
code: "reserved_attribute_keys",
params: {
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
});
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("returns success with only email attribute", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" });
+22 -3
View File
@@ -6,6 +6,10 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { prepareNewSDKAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import {
@@ -38,6 +42,7 @@ const MESSAGE_TEMPLATES: Record<string, string> = {
userid_already_exists: "The userId already exists for this environment and was not updated.",
invalid_attribute_keys:
"Skipped creating attribute(s) with invalid key(s): {keys}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
reserved_attribute_keys: "{issue}",
attribute_limit_exceeded:
"Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
new_attribute_created: "Created new attribute '{key}' with type '{dataType}'",
@@ -304,12 +309,15 @@ export const updateAttributes = async (
// Validate that new attribute keys are safe identifiers
const validNewAttributes: typeof newAttributes = [];
const invalidKeys: string[] = [];
const reservedKeys: string[] = [];
for (const attr of newAttributes) {
if (isSafeIdentifier(attr.key)) {
validNewAttributes.push(attr);
} else {
if (!isSafeIdentifier(attr.key)) {
invalidKeys.push(attr.key);
} else if (isReservedFutureDefaultAttributeKey(attr.key)) {
reservedKeys.push(attr.key);
} else {
validNewAttributes.push(attr);
}
}
@@ -325,6 +333,17 @@ export const updateAttributes = async (
);
}
if (reservedKeys.length > 0) {
errors.push({
code: "reserved_attribute_keys",
params: { issue: getReservedFutureDefaultAttributeKeyIssue(reservedKeys) },
});
logger.warn(
{ workspaceId, reservedKeys },
"SDK tried to create reserved future default attribute keys - skipping"
);
}
if (validNewAttributes.length > 0) {
const totalAttributeClassesLength = contactAttributeKeys.length + validNewAttributes.length;
@@ -147,6 +147,13 @@ describe("createContactAttributeKey", () => {
await expect(createContactAttributeKey({ workspaceId, key: "email" })).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when key is reserved for future defaults", async () => {
await expect(createContactAttributeKey({ workspaceId, key: "user_id" })).rejects.toThrow(
InvalidInputError
);
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("rethrows unknown prisma error codes", async () => {
const err = Object.assign(new Error("Some prisma error"), { code: PrismaErrorType.RecordDoesNotExist });
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(err);
@@ -4,6 +4,10 @@ import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "./attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceId: string): Promise<TContactAttributeKey[]> => {
@@ -31,6 +35,10 @@ export const createContactAttributeKey = async (data: {
description?: string;
dataType?: TContactAttributeDataType;
}): Promise<TContactAttributeKey> => {
if (isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
try {
const contactAttributeKey = await prisma.contactAttributeKey.create({
data: {
@@ -537,6 +537,22 @@ describe("Contacts Lib", () => {
).rejects.toThrow(ValidationError);
});
test("throws ValidationError when CSV creates reserved future default keys", async () => {
const reservedCsvData = [{ email: "john@example.com", user_id: "user-1" }];
const attributeMap = { email: "email", user_id: "user_id" };
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce([
{ key: "email", id: "key-1", dataType: "string" },
] as any);
await expect(
createContactsFromCSV(reservedCsvData as any, mockWorkspaceId, "skip", attributeMap)
).rejects.toThrow(ValidationError);
expect(prisma.contactAttributeKey.createMany).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma error", async () => {
const attributeMap = { email: "email" };
const prismaError = new Prisma.PrismaClientKnownRequestError("DB Error", {
@@ -9,6 +9,10 @@ import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import {
getReservedFutureDefaultAttributeKeyIssue,
getReservedFutureDefaultAttributeKeys,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
@@ -412,6 +416,11 @@ const createMissingAttributeKeys = async (
);
}
const reservedKeys = getReservedFutureDefaultAttributeKeys(missingKeys);
if (reservedKeys.length > 0) {
throw new ValidationError(getReservedFutureDefaultAttributeKeyIssue(reservedKeys));
}
// Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
const uniqueMissingKeys = new Map<string, string>();
for (const key of missingKeys) {
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
@@ -49,7 +49,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
if (surveyWorkspaceId !== parsedInput.workspaceId) {
throw new Error("Survey and segment are not in the same workspace");
throw new InvalidInputError("Survey and segment are not in the same workspace");
}
}
@@ -82,7 +82,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
throw new InvalidInputError(errMsg);
}
const segment = await createSegment(parsedInput);
@@ -139,7 +139,7 @@ export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdate
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
throw new InvalidInputError(errMsg);
}
await checkForRecursiveSegmentFilter(parsedFilters.data, parsedInput.segmentId);
@@ -169,7 +169,7 @@ export const loadNewSegmentAction = authenticatedActionClient
const segmentWorkspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId);
if (surveyWorkspaceId !== segmentWorkspaceId) {
throw new Error("Segment and survey are not in the same workspace");
throw new InvalidInputError("Segment and survey are not in the same workspace");
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
@@ -184,22 +184,33 @@ export function AddFilterModal({
[t]
);
const contactAttributeKeysForPicker = useMemo(() => {
// `userId` is represented by the person filter (fingerprint icon), so hide it from attribute entries.
return contactAttributeKeys.filter((attributeKey) => attributeKey.key !== "userId");
}, [contactAttributeKeys]);
const contactAttributeKeysFiltered = useMemo(() => {
if (!contactAttributeKeys) return [];
if (!contactAttributeKeysForPicker) return [];
if (!searchValue) return contactAttributeKeys;
if (!searchValue) return contactAttributeKeysForPicker;
return contactAttributeKeys.filter((attributeKey) => {
return contactAttributeKeysForPicker.filter((attributeKey) => {
const attributeValueToSeach = attributeKey.name ?? attributeKey.key;
return attributeValueToSeach.toLowerCase().includes(searchValue.toLowerCase());
});
}, [contactAttributeKeys, searchValue]);
}, [contactAttributeKeysForPicker, searchValue]);
const contactAttributeFiltered = useMemo(() => {
const contactAttributes = [{ name: "userId" }];
const personIdentifiers = [{ id: "userId", label: t("common.user_id") }];
return contactAttributes.filter((ca) => ca.name.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return personIdentifiers.filter((personIdentifier) => {
const query = searchValue.toLowerCase();
return (
personIdentifier.id.toLowerCase().includes(query) ||
personIdentifier.label.toLowerCase().includes(query)
);
});
}, [searchValue, t]);
const segmentsFiltered = useMemo(() => {
if (!segments) return [];
@@ -283,10 +294,10 @@ export function AddFilterModal({
{filters.contactAttributeFiltered.map((personAttribute) => (
<FilterButton
key={personAttribute.name}
data-testid={`filter-btn-person-${personAttribute.name}`}
key={personAttribute.id}
data-testid={`filter-btn-person-${personAttribute.id}`}
icon={<FingerprintIcon className="h-4 w-4" />}
label={personAttribute.name}
label={personAttribute.label}
onClick={() => {
handleAddFilter({
type: "person",
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -123,7 +123,11 @@ export const SurveyLoadingAnimation = ({
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
)}>
{isBrandingEnabled && (
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
<Image
src={Logo as string}
alt="Logo"
className={cn("w-32 transition-all duration-1000 md:w-40")}
/>
)}
<LoadingSpinner />
</div>
@@ -99,7 +99,12 @@ describe("useDeleteSurvey", () => {
0
);
resolveFetch?.(new Response(null, { status: 204 }));
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
@@ -1,10 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { V3ApiError } from "@/modules/api/lib/v3-client";
import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client";
afterEach(() => {
vi.unstubAllGlobals();
});
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
@@ -44,39 +39,3 @@ describe("buildSurveyListSearchParams", () => {
);
});
});
describe("deleteSurvey", () => {
test("treats 204 No Content as a successful delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await expect(deleteSurvey("survey_1")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", {
method: "DELETE",
cache: "no-store",
});
});
test("maps v3 problem responses to V3ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json(
{
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
},
{ status: 403 }
)
)
);
await expect(deleteSurvey("survey_1")).rejects.toMatchObject<V3ApiError>({
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
});
});
});
@@ -13,6 +13,12 @@ type TV3SurveyListResponse = {
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
@@ -116,7 +122,7 @@ export async function listSurveys({
};
}
export async function deleteSurvey(surveyId: string): Promise<void> {
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
@@ -125,4 +131,7 @@ export async function deleteSurvey(surveyId: string): Promise<void> {
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
<DropdownMenuPrimitive.SubContent
ref={ref as any}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
className
)}
{...props}
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
className
)}
{...props}
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
className
)}
{...props}
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
className
)}
{...props}
@@ -9,6 +9,8 @@ import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formb
import { validateInputs } from "@/lib/utils/validate";
import { deleteFilesByWorkspaceId } from "@/modules/storage/service";
// Keep v5 defaults aligned with current production camelCase keys.
// Safe-identifier migration (with backwards compatibility) is intentionally deferred to v5.1.
const DEFAULT_CONTACT_ATTRIBUTE_KEYS: Prisma.ContactAttributeKeyCreateWithoutWorkspaceInput[] = [
{
key: "userId",
@@ -151,7 +153,7 @@ export const deleteWorkspace = async (workspaceId: string): Promise<TWorkspace>
if (workspace) {
const s3Result = await deleteFilesByWorkspaceId(workspaceId, []);
if (!s3Result.ok) {
if (!s3Result.ok && "error" in s3Result) {
// fail silently because we don't want to throw an error if the files are not deleted
logger.error(s3Result.error, "Error deleting S3 files");
}
-2
View File
@@ -10,8 +10,6 @@
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
"build:dev": "pnpm run build",
"start": "next start",
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
-8
View File
@@ -1,8 +0,0 @@
import "@prisma/client";
declare module "@prisma/client" {
namespace Prisma {
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"exclude": [
"../../.env",
".next",
"node_modules",
"playwright",
"**/*.test.ts",
"**/*.test.tsx",
"**/tests/**",
"**/__mocks__/**",
"**/__tests__/**"
],
"extends": "./tsconfig.json",
"include": [
"next-env.d.ts",
"**/*.d.ts",
"app/**/*.ts",
"app/**/*.tsx",
"lib/**/*.ts",
"lib/**/*.tsx",
"modules/**/*.ts",
"modules/**/*.tsx",
"scripts/**/*.ts",
"../../packages/types/*.d.ts"
]
}
+2 -5
View File
@@ -2,14 +2,11 @@ dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 16.4.16
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 20.11.2
- name: gateway-helm
repository: oci://docker.io/envoyproxy
version: v1.7.1
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 20.11.2
digest: sha256:d93a29cb9cbef513db410ded5aa199f219c5b25ce1870e1e049dc470acc34235
generated: "2026-04-08T00:33:09.875832+05:30"
digest: sha256:078652b37649ba5bb72f7463709356c08f464cb989270b97d095b8243c0e58b9
generated: "2026-05-21T12:52:44.141547+05:30"
+2 -6
View File
@@ -1,6 +1,6 @@
apiVersion: v2
name: formbricks
description: A Helm chart for Formbricks with PostgreSQL, Redis
description: A Helm chart for Formbricks with PostgreSQL, Valkey
type: application
@@ -15,7 +15,7 @@ icon: https://formbricks.com/favicon.ico
keywords:
- formbricks
- postgresql
- redis
- valkey
home: https://formbricks.com/docs/self-hosting/setup/kubernetes
maintainers:
@@ -27,10 +27,6 @@ dependencies:
version: "16.4.16"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
- name: redis
version: 20.11.2
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled
- name: gateway-helm
alias: envoy
version: "v1.7.1"
+24 -4
View File
@@ -2,7 +2,7 @@
![Version: 0.0.0-dev](https://img.shields.io/badge/Version-0.0.0--dev-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 5.0.0-rc.1](https://img.shields.io/badge/AppVersion-5.0.0--rc.1-informational?style=flat-square)
A Helm chart for Formbricks with PostgreSQL, Redis
A Helm chart for Formbricks with PostgreSQL, Valkey
**Homepage:** <https://formbricks.com/docs/self-hosting/setup/kubernetes>
@@ -17,9 +17,8 @@ A Helm chart for Formbricks with PostgreSQL, Redis
| Repository | Name | Version |
| ---------------------------------------- | ------------ | ------- |
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.4.16 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
| oci://docker.io/envoyproxy | gateway-helm | v1.7.1 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
| oci://registry-1.docker.io/bitnamicharts | envoyRedis | 20.11.2 |
## Envoy bundle modes
@@ -32,7 +31,7 @@ rate limiting.
Gateway API CRDs plus an Envoy Gateway controller compatible with
`envoy.config.envoyGateway.gateway.controllerName`.
- `envoyRedis.enabled=true` deploys a dedicated Redis replication + Sentinel bundle for Envoy RLS. It is intentionally
separate from the existing app `redis` dependency.
separate from the bundled app Valkey deployment.
- The bundled controller reads its Redis backend from `envoy.config.envoyGateway.rateLimit.backend.redis.url`.
If you enable Redis authentication or override `envoyRedis.fullnameOverride`, set that URL explicitly so the
controller points at the correct backend.
@@ -289,7 +288,28 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| redis.enabled | bool | `true` | |
| redis.externalRedisUrl | string | `""` | |
| redis.fullnameOverride | string | `"formbricks-redis"` | |
| redis.image.digest | string | `"sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d"` | |
| redis.image.pullPolicy | string | `"IfNotPresent"` | |
| redis.image.repository | string | `"valkey/valkey"` | |
| redis.image.tag | string | `""` | |
| redis.master.affinity | object | `{}` | |
| redis.master.containerSecurityContext | object | `{}` | |
| redis.master.nodeSelector | object | `{}` | |
| redis.master.pdb.enabled | bool | `true` | |
| redis.master.pdb.maxUnavailable | int | `1` | |
| redis.master.pdb.minAvailable | string | `""` | |
| redis.master.persistence.accessModes[0] | string | `"ReadWriteOnce"` | |
| redis.master.persistence.enabled | bool | `true` | |
| redis.master.persistence.size | string | `"8Gi"` | |
| redis.master.persistence.storageClass | string | `""` | |
| redis.master.podAnnotations | object | `{}` | |
| redis.master.podSecurityContext | object | `{}` | |
| redis.master.resources.limits.cpu | string | `"150m"` | |
| redis.master.resources.limits.memory | string | `"192Mi"` | |
| redis.master.resources.requests.cpu | string | `"100m"` | |
| redis.master.resources.requests.memory | string | `"128Mi"` | |
| redis.master.tolerations | list | `[]` | |
| redis.master.topologySpreadConstraints | list | `[]` | |
| redis.networkPolicy.enabled | bool | `false` | |
| secret.enabled | bool | `true` | |
| service.additionalLabels | object | `{}` | |
+1 -1
View File
@@ -69,7 +69,7 @@ Redis Access:
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.REDIS_PASSWORD}" | base64 --decode
```
Connection details:
- **Host**: `{{ include "formbricks.name" . }}-redis-master`
- **Host**: `{{ include "formbricks.redisMasterName" . }}`
- **Port**: `6379`
{{- else if .Values.redis.externalRedisUrl }}
You're using an external Redis instance.
+20
View File
@@ -125,6 +125,26 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
{{- end }}
{{- define "formbricks.redisName" -}}
{{- .Values.redis.fullnameOverride | default (printf "%s-redis" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisMasterName" -}}
{{- printf "%s-master" (include "formbricks.redisName" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisHeadlessName" -}}
{{- printf "%s-headless" (include "formbricks.redisName" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisImage" -}}
{{- if .Values.redis.image.digest -}}
{{- printf "%s@%s" .Values.redis.image.repository .Values.redis.image.digest -}}
{{- else -}}
{{- printf "%s:%s" .Values.redis.image.repository .Values.redis.image.tag -}}
{{- end -}}
{{- end }}
{{- define "formbricks.hubSecretName" -}}
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
{{- end }}

Some files were not shown because too many files have changed in this diff Show More