mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-21 19:59:39 -05:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 842d72eb69 | |||
| 6183ab4744 | |||
| 431a3d8a76 | |||
| 91ab958379 | |||
| e5df832653 | |||
| 0909c38eb1 | |||
| 08b0d95295 | |||
| a49e989413 | |||
| 9445b2f482 | |||
| f07d832516 | |||
| e615c692a9 | |||
| a9a910d15c | |||
| c425e7aff4 | |||
| a83a54a24a | |||
| f7890eaec3 | |||
| 8cd3187eff | |||
| 83bccc7ded | |||
| 00aa6d5247 | |||
| 0657c94ee5 | |||
| a36cef2936 | |||
| 467af8b6ef | |||
| d0e057eac1 | |||
| ef1f5a2b12 | |||
| 770041923f | |||
| 3e66ff25a1 | |||
| c979909da9 | |||
| 010d96ebcd | |||
| a0b3054f4a | |||
| 02d3cd2af3 | |||
| 2ef4eb4345 | |||
| 093757b386 | |||
| 64f8746940 | |||
| 851616078a | |||
| 06d5313629 | |||
| 7834c21d39 | |||
| f98ca39035 | |||
| 48f928b1bf | |||
| f5dfb4739c | |||
| 5a1fc01388 | |||
| 77a39c13fa | |||
| 5a12539c75 | |||
| 74e0fba757 | |||
| d9c2756185 | |||
| 88ad5c8625 | |||
| 610beee7eb | |||
| db0e2bb105 | |||
| 419ceef413 |
@@ -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 */}
|
||||
|
||||
@@ -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,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
-2
@@ -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;
|
||||
}
|
||||
|
||||
+2
-30
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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,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")) {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "まだアクティビティがありません",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Пока нет активности",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "暂无活动",
|
||||
|
||||
@@ -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": "尚無活動",
|
||||
|
||||
+11
@@ -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: {
|
||||
|
||||
+22
@@ -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,
|
||||
|
||||
+20
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+9
-1
@@ -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: {
|
||||
|
||||
+16
-4
@@ -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(),
|
||||
});
|
||||
|
||||
+12
-1
@@ -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";
|
||||
|
||||
+9
-1
@@ -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);
|
||||
|
||||
+36
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
  
|
||||
|
||||
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 | `{}` | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user