Compare commits

..

25 Commits

Author SHA1 Message Date
Tiago Farto b525f7bdbb fix: support script-region survey locales 2026-05-21 15:00:40 +00:00
Tiago Farto 9f9009497e fix: tighten v3 survey locale selectors 2026-05-21 14:40:17 +00:00
Tiago Farto 7ad0f8b21f chore: api v3 get survey 2026-05-21 11:54:46 +00:00
Dhruwang Jariwala f6aa27ba8c fix: chart date range type switch + presets include today (ENG-1034, ENG-1035) (#8096)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-21 11:05:10 +00:00
Johannes 82765f7dd7 fix: allow enterprise oauth display names (#8099)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:59:35 +00:00
Dhruwang Jariwala d5bbafcf90 fix: remount AI translation editor on value change, not disabled transition (#8084) 2026-05-21 10:09:57 +00:00
Anshuman Pandey db87a588b5 fix: adds close button on response error screen (#8093) 2026-05-21 09:26:47 +00:00
Javi Aguilar c834587c8d chore: add typecheck command and fix format and type issues (#7999) 2026-05-21 08:13:46 +00:00
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
64 changed files with 2438 additions and 544 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { App } from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -1,10 +1,11 @@
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 Prisma.PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
});
test("returns a bad request response when the response id is missing", async () => {
@@ -245,6 +245,34 @@ 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(),
+13 -13
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
@@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: 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(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(getWorkspace).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(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(getWorkspace).not.toHaveBeenCalled();
});
test("returns 403 when workspace is not found (avoid leaking existence)", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).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(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
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(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
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(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" });
vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k");
});
@@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("delegates to session flow when user is present", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any);
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(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k");
expect(getWorkspace).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(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
const auth = {
...keyBase,
workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)],
+13 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -13,7 +14,7 @@ import {
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
invalid_params: [{ name: "x", reason: "y", identifier: "canonical-x" }],
instance: "/p",
});
expect(res.status).toBe(400);
@@ -21,7 +22,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" }]);
expect(body.invalid_params).toEqual([{ name: "x", reason: "y", identifier: "canonical-x" }]);
expect(body.instance).toBe("/p");
});
@@ -118,3 +119,13 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
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("");
});
});
+16 -1
View File
@@ -6,7 +6,7 @@
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type InvalidParam = { name: string; reason: string; identifier?: string };
export type ProblemExtension = {
code?: string;
@@ -171,3 +171,18 @@ export function successResponse<T>(
}
);
}
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,45 +1,34 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
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(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getWorkspace).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(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc");
expect(getWorkspace).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(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
});
});
+5 -6
View File
@@ -6,7 +6,7 @@
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
/**
* Internal IDs derived from a V3 workspace identifier.
@@ -19,21 +19,20 @@ export type V3WorkspaceContext = {
};
/**
* Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId.
* Resolves a V3 API workspaceId to internal workspaceId and organizationId.
*
* @throws ResourceNotFoundError if the workspace does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId);
const workspace = await getWorkspace(workspaceId);
if (!workspace) {
throw new ResourceNotFoundError("workspace", workspaceId);
}
const canonicalId = workspace.id;
const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id);
return {
workspaceId: canonicalId,
workspaceId: workspace.id,
organizationId,
};
}
@@ -1,318 +0,0 @@
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,
}),
})
);
});
});
+121 -27
View File
@@ -2,42 +2,141 @@ 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 { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
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: z.object({
surveyId: z.cuid2(),
}),
params: surveyParamsSchema,
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
const { survey, authResult, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
survey.workspaceId,
"readWrite",
access: "readWrite",
requestId,
instance
);
instance,
});
if (authResult instanceof Response) {
return authResult;
if (response) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
}
if (auditLog) {
@@ -46,14 +145,9 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
return noContentResponse({ requestId });
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -0,0 +1,71 @@
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,
});
});
});
@@ -0,0 +1,37 @@
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 };
}
@@ -0,0 +1,120 @@
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",
});
});
});
+97
View File
@@ -0,0 +1,97 @@
type TV3SurveyLanguageInput = {
code: string;
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`,
};
}
@@ -0,0 +1,290 @@
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" },
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({
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"
);
});
});
+185 -3
View File
@@ -1,13 +1,195 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
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";
}
}
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurveyListRecord): 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 getSurveyLanguages(survey: TInternalSurvey): 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: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
}
return languages;
}
function getDefaultLanguage(survey: TInternalSurvey): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: DEFAULT_V3_SURVEY_LANGUAGE;
}
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 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 = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
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: survey.metadata,
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
+2 -1
View File
@@ -1,5 +1,6 @@
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";
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
@@ -38,6 +38,50 @@ 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", () => {
@@ -60,4 +104,54 @@ 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");
});
});
+36 -1
View File
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
import { createUser, getUser, getUserByEmail, updateUser, updateUserLastLoginAt } from "./user";
@@ -53,6 +53,41 @@ describe("User Management", () => {
expect(result).toEqual(mockPrismaUser);
});
test("creates a user with an Azure AD enterprise display name", async () => {
const enterpriseDisplayName = "Lastname,Firstname (DEPT) COMPANY-CITY";
vi.mocked(prisma.user.create).mockResolvedValueOnce({
...mockPrismaUser,
name: enterpriseDisplayName,
});
const result = await createUser({
email: mockUser.email,
name: enterpriseDisplayName,
locale: mockUser.locale,
});
expect(result.name).toBe(enterpriseDisplayName);
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: enterpriseDisplayName,
}),
})
);
});
test("rejects display names with newline characters", async () => {
await expect(
createUser({
email: mockUser.email,
name: "Lastname,Firstname\n(DEPT) COMPANY-CITY",
locale: mockUser.locale,
})
).rejects.toThrow(ValidationError);
expect(prisma.user.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError when email already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
@@ -3,6 +3,7 @@ import cubejs, { type Query } from "@cubejs-client/core";
import { randomUUID } from "node:crypto";
import { logger } from "@formbricks/logger";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "@/modules/ee/analysis/lib/date-presets";
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { type TCubeQuerySource, getCubeApiConfig } from "./cube-config";
@@ -89,7 +90,7 @@ export async function executeTenantScopedQuery(input: TScopedCubeQueryInput) {
try {
const client = cubejs(token, { apiUrl });
const resultSet = await client.load(input.query as Query);
const resultSet = await client.load(expandPresetDateRanges(input.query) as Query);
const result = resultSet.tablePivot();
queueCubeQueryAuditEvent({ input, requestId, status: "success" });
return result;
@@ -83,6 +83,24 @@ export function TimeDimensionPanel({
}
};
const handleDateRangeTypeChange = (value: "preset" | "custom") => {
setDateRangeType(value);
if (!timeDimension) return;
if (value === "preset") {
const nextPreset = presetValue || "last 30 days";
if (!presetValue) setPresetValue(nextPreset);
onTimeDimensionChange({ ...timeDimension, dateRange: nextPreset });
return;
}
const start = customStartDate ?? new Date();
const end = customEndDate ?? start;
if (!customStartDate) setCustomStartDate(start);
if (!customEndDate) setCustomEndDate(end);
onTimeDimensionChange({ ...timeDimension, dateRange: [start, end] });
};
if (!timeDimension) {
return (
<div className="space-y-2">
@@ -150,7 +168,7 @@ export function TimeDimensionPanel({
<div className="space-y-2">
<Select
value={dateRangeType}
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
onValueChange={(value) => handleDateRangeTypeChange(value as "preset" | "custom")}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
@@ -0,0 +1,96 @@
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);
});
});
@@ -0,0 +1,37 @@
import { addDays, formatDate, startOfDay, startOfMonth, startOfQuarter, startOfYear } from "date-fns";
import type { TChartQuery } from "@formbricks/types/analysis";
// Cube's native "last N days" / "this month" / etc. strings exclude today; we expand them
// to explicit inclusive ranges so charts behave like every other analytics tool (GA, Mixpanel,
// PostHog, ...) and include the current partial day.
const PRESET_RESOLVERS: Record<string, (now: Date) => [Date, Date]> = {
today: (now) => [startOfDay(now), startOfDay(now)],
yesterday: (now) => [addDays(startOfDay(now), -1), addDays(startOfDay(now), -1)],
"last 7 days": (now) => [addDays(startOfDay(now), -6), startOfDay(now)],
"last 30 days": (now) => [addDays(startOfDay(now), -29), startOfDay(now)],
"this month": (now) => [startOfMonth(now), startOfDay(now)],
"last month": (now) => {
const firstOfThisMonth = startOfMonth(now);
const lastOfLastMonth = addDays(firstOfThisMonth, -1);
return [startOfMonth(lastOfLastMonth), lastOfLastMonth];
},
"this quarter": (now) => [startOfQuarter(now), startOfDay(now)],
"this year": (now) => [startOfYear(now), startOfDay(now)],
};
export const expandPresetDateRanges = (query: TChartQuery, now: Date = new Date()): TChartQuery => {
if (!query.timeDimensions?.length) return query;
const expanded = query.timeDimensions.map((td) => {
if (typeof td.dateRange !== "string") return td;
const resolver = PRESET_RESOLVERS[td.dateRange.toLowerCase().trim()];
if (!resolver) return td;
const [start, end] = resolver(now);
return {
...td,
dateRange: [formatDate(start, "yyyy-MM-dd"), formatDate(end, "yyyy-MM-dd")] as [string, string],
};
});
return { ...query, timeDimensions: expanded };
};
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -6,6 +8,17 @@ 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", () => {
@@ -94,5 +107,20 @@ 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());
});
});
});
@@ -141,7 +141,7 @@ export const UploadContactsAttributeCombobox = ({
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")} {searchValue.trim()}
+ Add {searchValue}
</button>
) : (
<div className="flex flex-col py-1 text-xs text-slate-500">
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo} 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 as string} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -123,11 +123,7 @@ export const SurveyLoadingAnimation = ({
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
)}>
{isBrandingEnabled && (
<Image
src={Logo as string}
alt="Logo"
className={cn("w-32 transition-all duration-1000 md:w-40")}
/>
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
)}
<LoadingSpinner />
</div>
@@ -99,12 +99,7 @@ describe("useDeleteSurvey", () => {
0
);
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
resolveFetch?.(new Response(null, { status: 204 }));
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
@@ -1,5 +1,10 @@
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
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();
});
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
@@ -39,3 +44,39 @@ 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,12 +13,6 @@ type TV3SurveyListResponse = {
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
@@ -122,7 +116,7 @@ export async function listSurveys({
};
}
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
export async function deleteSurvey(surveyId: string): Promise<void> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
@@ -131,7 +125,4 @@ export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
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(
"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",
"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",
className
)}
{...props}
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
ref={ref}
sideOffset={sideOffset}
className={cn(
"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",
"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",
className
)}
{...props}
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
align={align}
sideOffset={sideOffset}
className={cn(
"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",
"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",
className
)}
{...props}
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
ref={ref}
sideOffset={sideOffset}
className={cn(
"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",
"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",
className
)}
{...props}
+2
View File
@@ -10,6 +10,8 @@
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
"build:dev": "pnpm run build",
"start": "next start",
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
+8
View File
@@ -0,0 +1,8 @@
import "@prisma/client";
declare module "@prisma/client" {
namespace Prisma {
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"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"
]
}
+861 -25
View File
@@ -1,19 +1,16 @@
# V3 API — Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
description: |
**GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**GET /api/v3/surveys**, **GET /api/v3/surveys/{surveyId}**, and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
**workspaceId**
Query param `workspaceId` is the canonical container identifier for this API.
**Deprecated compatibility:** the older `environmentId` identifier is still accepted
for compatibility but should no longer be used in new integrations.
**Auth**
Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation.
@@ -34,7 +31,7 @@ info:
The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
Additional v3 survey write endpoints, optional ETag/304, field selection, and survey version history.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
@@ -51,7 +48,6 @@ paths:
summary: List surveys
description: |
Returns surveys for the workspace. Session cookie or x-api-key.
Note: Environments are deprecated. Use workspace/workspaceId terminology.
tags:
- V3 Surveys
parameters:
@@ -63,15 +59,6 @@ paths:
format: cuid2
description: |
Workspace identifier. This is the canonical container ID for v3 APIs.
- in: query
name: environmentId
required: false
deprecated: true
schema:
type: string
format: cuid2
description: |
Deprecated: use `workspaceId`. This alias is retained for backward compatibility.
- in: query
name: limit
schema:
@@ -198,6 +185,177 @@ paths:
- sessionAuth: []
- apiKeyAuth: []
/api/v3/surveys/{surveyId}:
get:
operationId: getSurveyV3
summary: Retrieve a survey
description: |
Returns the public v3 survey management resource for one survey. By default, translatable
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
to filter those maps to one or more requested locale codes.
tags:
- V3 Surveys
parameters:
- in: path
name: surveyId
required: true
schema:
type: string
format: cuid2
description: Survey identifier.
- in: query
name: lang
required: false
style: form
explode: false
schema:
type: array
items:
type: string
pattern: "^[a-z]{2}(-[A-Z][a-z]{3})?-[A-Z]{2}$"
examples:
- [de-DE]
- [de-DE, pt-PT]
- [zh-Hans-CN]
description: |
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
The response shape stays stable: translatable fields are always maps keyed by locale code, never
strings. Send canonical locale codes (`de-DE`, `zh-Hans-CN`, not `de`). For interoperability, the
parser is case-insensitive, accepts `_` or `-` separators, and normalizes accepted region-qualified
tags (`de_DE`, `DE-de` → `de-DE`; `zh_hans_cn` → `zh-Hans-CN`). Language-only selectors (`de`) and
aliases are not accepted.
Disabled-but-configured languages are readable in the management API so unfinished translations can
be completed.
responses:
"200":
description: Survey retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
examples:
canonical:
summary: Canonical multilingual authoring resource
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
en-US: How satisfied are you with our product?
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
filtered:
summary: Language-filtered projection with ?lang=de-DE
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
"400":
description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
delete:
operationId: deleteSurveyV3
summary: Delete a survey
@@ -213,7 +371,7 @@ paths:
format: cuid2
description: Survey identifier.
responses:
"200":
"204":
description: Survey deleted successfully
headers:
X-Request-Id:
@@ -222,10 +380,6 @@ paths:
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyDeleteResponse"
"400":
description: Bad Request
content:
@@ -304,15 +458,694 @@ components:
properties:
enabled: { type: boolean }
isEncrypted: { type: boolean }
SurveyDeleteResponse:
TranslatableText:
allOf:
- $ref: "#/components/schemas/TranslatableTextMap"
description: |
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
The internal storage key `default` is never exposed by v3.
examples:
- en-US: What should we improve?
de-DE: Was sollten wir verbessern?
TranslatableTextMap:
type: object
required: [data]
description: Canonical multilingual text map keyed by real locale codes.
propertyNames:
type: string
description: BCP 47 locale code, for example `en-US`, `de-DE`, or `pt-BR`.
additionalProperties:
type: string
SurveyLanguage:
type: object
description: |
Language configured for this survey. Aliases/display names are intentionally not exposed in v3.
Disabled languages can still be read by the management API so unfinished translations can be completed.
required: [code, default, enabled]
properties:
data:
code:
type: string
description: Canonical locale code.
example: en-US
default:
type: boolean
description: Whether this is the default authoring language.
enabled:
type: boolean
description: Whether this language is enabled for respondent-facing delivery.
SurveyWelcomeCard:
type: object
description: Optional card shown before the first survey block.
required: [enabled]
properties:
enabled:
type: boolean
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
fileUrl:
type: string
videoUrl:
type: string
timeToFinish:
type: boolean
showResponseCount:
type: boolean
additionalProperties: true
SurveyHiddenFields:
type: object
description: |
Hidden fields, sometimes called embedded data in other survey products. Field ids are stable
public identifiers and may be referenced by logic, recall, quotas, integrations, and response data.
Use only letters, numbers, underscores, and hyphens; avoid spaces and reserved ids.
required: [enabled]
properties:
enabled:
type: boolean
fieldIds:
type: array
items:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
uniqueItems: true
additionalProperties: false
SurveyVariable:
oneOf:
- $ref: "#/components/schemas/SurveyNumberVariable"
- $ref: "#/components/schemas/SurveyTextVariable"
description: |
Survey variable. Variable ids are stable references used by logic and calculation actions.
Variable names are human-readable labels and must be unique within the survey.
SurveyNumberVariable:
type: object
description: |
Number variable. Used by `calculate` logic actions with numeric operators such as `add`,
`subtract`, `multiply`, `divide`, or `assign`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [number]
value:
type: number
description: Default numeric value.
additionalProperties: false
SurveyTextVariable:
type: object
description: |
Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [text]
value:
type: string
description: Default text value.
additionalProperties: false
SurveyEnding:
type: object
description: Ending reached after the last block or a jump action.
required: [id, type]
properties:
id:
type: string
format: cuid2
description: Stable ending id. `jumpToBlock.target` may point to this id.
type:
type: string
enum: [endScreen, redirectToUrl]
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
buttonLink:
type: string
imageUrl:
type: string
videoUrl:
type: string
url:
type: string
description: Redirect URL for `redirectToUrl` endings.
label:
type: string
description: Optional internal label for redirect endings.
additionalProperties: true
SurveyBlock:
type: object
description: |
Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can
jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally
creating/deleting a block.
required: [id, name, elements]
properties:
id:
type: string
format: cuid2
description: Stable block id.
name:
type: string
minLength: 1
elements:
type: array
minItems: 1
items:
$ref: "#/components/schemas/SurveyElement"
logic:
type: array
items:
$ref: "#/components/schemas/SurveyBlockLogic"
logicFallback:
type: string
format: cuid2
description: Block or ending id used when no logic condition matches.
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
backButtonLabel:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: true
SurveyElement:
type: object
description: |
Survey element/question inside a block. Element ids are stable public identifiers used by
logic, recall strings, response data, quotas, integrations, and analysis. The schema lists
the fields used by all current element types; type-specific fields are present only when relevant.
required: [id, type, headline, required]
properties:
id:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
description: Stable element id. Avoid spaces and reserved ids.
type:
type: string
enum:
- openText
- multipleChoiceSingle
- multipleChoiceMulti
- nps
- rating
- csat
- ces
- consent
- pictureSelection
- cta
- date
- fileUpload
- cal
- matrix
- address
- ranking
- contactInfo
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
required:
type: boolean
imageUrl:
type: string
videoUrl:
type: string
isDraft:
type: boolean
description: Draft marker used by the editor and future update rules.
placeholder:
$ref: "#/components/schemas/TranslatableText"
longAnswer:
type: boolean
description: "`openText` only."
inputType:
type: string
enum: [text, email, url, number, phone]
description: "`openText` only."
charLimit:
type: object
required: [id]
description: "`openText` character limit configuration."
properties:
id: { type: string }
enabled:
type: boolean
min:
type: number
max:
type: number
additionalProperties: false
choices:
type: array
description: Choice list for multiple choice, ranking, and picture selection elements.
items:
oneOf:
- $ref: "#/components/schemas/SurveyChoice"
- $ref: "#/components/schemas/SurveyPictureChoice"
shuffleOption:
type: string
enum: [none, all, exceptLast, reverseOrderOccasionally, reverseOrderExceptLast]
displayType:
type: string
enum: [list, dropdown]
description: Multiple choice display style.
otherOptionPlaceholder:
$ref: "#/components/schemas/TranslatableText"
lowerLabel:
$ref: "#/components/schemas/TranslatableText"
upperLabel:
$ref: "#/components/schemas/TranslatableText"
isColorCodingEnabled:
type: boolean
scale:
type: string
enum: [number, smiley, star]
description: Rating, CSAT, CES, or NPS scale display.
range:
type: integer
enum: [3, 4, 5, 6, 7, 10]
description: Rating range. CSAT is always 5; CES is 5 or 7.
label:
$ref: "#/components/schemas/TranslatableText"
description: Consent checkbox label.
allowMulti:
type: boolean
description: "`pictureSelection` only."
buttonExternal:
type: boolean
description: "`cta` only."
buttonUrl:
type: string
description: "`cta` only."
ctaButtonLabel:
$ref: "#/components/schemas/TranslatableText"
html:
$ref: "#/components/schemas/TranslatableText"
description: "`date` helper copy."
format:
type: string
enum: [M-d-y, d-M-y, y-M-d]
description: "`date` only."
allowMultipleFiles:
type: boolean
description: "`fileUpload` only."
maxSizeInMB:
type: number
description: "`fileUpload` only."
allowedFileExtensions:
type: array
items:
type: string
description: "`fileUpload` only."
calUserName:
type: string
description: "`cal` only."
calHost:
type: string
description: "`cal` only."
rows:
type: array
description: Matrix rows.
items:
$ref: "#/components/schemas/SurveyChoice"
columns:
type: array
description: Matrix columns.
items:
$ref: "#/components/schemas/SurveyChoice"
addressLine1:
$ref: "#/components/schemas/SurveyToggleInputConfig"
addressLine2:
$ref: "#/components/schemas/SurveyToggleInputConfig"
city:
$ref: "#/components/schemas/SurveyToggleInputConfig"
state:
$ref: "#/components/schemas/SurveyToggleInputConfig"
zip:
$ref: "#/components/schemas/SurveyToggleInputConfig"
country:
$ref: "#/components/schemas/SurveyToggleInputConfig"
firstName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
lastName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
email:
$ref: "#/components/schemas/SurveyToggleInputConfig"
phone:
$ref: "#/components/schemas/SurveyToggleInputConfig"
company:
$ref: "#/components/schemas/SurveyToggleInputConfig"
validation:
$ref: "#/components/schemas/SurveyValidation"
additionalProperties: true
SurveyChoice:
type: object
required: [id, label]
properties:
id:
type: string
description: Stable choice id.
label:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyPictureChoice:
type: object
required: [id, imageUrl]
properties:
id:
type: string
description: Stable picture choice id.
imageUrl:
type: string
additionalProperties: false
SurveyToggleInputConfig:
type: object
description: Field config for address and contact info elements.
required: [show, required, placeholder]
properties:
show:
type: boolean
required:
type: boolean
placeholder:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyValidation:
type: object
description: Optional element-level validation rules.
required: [rules]
properties:
logic:
type: string
enum: [and, or]
default: and
rules:
type: array
items:
$ref: "#/components/schemas/SurveyValidationRule"
additionalProperties: false
SurveyValidationRule:
type: object
required: [id, type, params]
properties:
id:
type: string
type:
type: string
enum:
- minLength
- maxLength
- pattern
- email
- url
- phone
- equals
- doesNotEqual
- contains
- doesNotContain
- minValue
- maxValue
- isGreaterThan
- isLessThan
- minSelections
- maxSelections
- minRanked
- rankAll
- minRowsAnswered
- answerAllRows
- isLaterThan
- isEarlierThan
- isBetween
- isNotBetween
- fileExtensionIs
- fileExtensionIsNot
params:
type: object
additionalProperties: true
field:
type: string
enum:
[
addressLine1,
addressLine2,
city,
state,
zip,
country,
firstName,
lastName,
email,
phone,
company,
]
additionalProperties: false
SurveyBlockLogic:
type: object
description: Conditional logic rule evaluated at block level.
required: [id, conditions, actions]
properties:
id:
type: string
format: cuid2
conditions:
$ref: "#/components/schemas/SurveyConditionGroup"
actions:
type: array
items:
$ref: "#/components/schemas/SurveyLogicAction"
additionalProperties: false
SurveyConditionGroup:
type: object
required: [id, connector, conditions]
properties:
id:
type: string
format: cuid2
connector:
type: string
enum: [and, or]
conditions:
type: array
items:
oneOf:
- $ref: "#/components/schemas/SurveyCondition"
- $ref: "#/components/schemas/SurveyConditionGroup"
additionalProperties: false
SurveyCondition:
type: object
description: |
Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`,
`isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do.
required: [id, leftOperand, operator]
properties:
id:
type: string
format: cuid2
leftOperand:
$ref: "#/components/schemas/SurveyDynamicReference"
operator:
type: string
enum:
- equals
- doesNotEqual
- contains
- doesNotContain
- startsWith
- doesNotStartWith
- endsWith
- doesNotEndWith
- isSubmitted
- isSkipped
- isGreaterThan
- isLessThan
- isGreaterThanOrEqual
- isLessThanOrEqual
- equalsOneOf
- includesAllOf
- includesOneOf
- doesNotIncludeOneOf
- doesNotIncludeAllOf
- isClicked
- isNotClicked
- isAccepted
- isBefore
- isAfter
- isBooked
- isPartiallySubmitted
- isCompletelySubmitted
- isSet
- isNotSet
- isEmpty
- isNotEmpty
- isAnyOf
rightOperand:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyLogicOperand:
oneOf:
- type: object
required: [type, value]
properties:
type:
type: string
enum: [static]
value:
oneOf:
- type: string
- type: number
- type: array
items:
type: string
additionalProperties: false
- $ref: "#/components/schemas/SurveyDynamicReference"
SurveyDynamicReference:
type: object
description: Dynamic reference to another value in the survey document.
required: [type, value]
properties:
type:
type: string
enum: [element, variable, hiddenField]
value:
type: string
description: Element id, variable id, or hidden field id depending on `type`.
meta:
type: object
additionalProperties:
type: string
additionalProperties: false
SurveyLogicAction:
oneOf:
- $ref: "#/components/schemas/SurveyCalculateAction"
- $ref: "#/components/schemas/SurveyRequireAnswerAction"
- $ref: "#/components/schemas/SurveyJumpToBlockAction"
description: |
Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id,
`requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id
or ending id.
SurveyCalculateAction:
type: object
description: Updates a survey variable when the logic rule matches.
required: [id, objective, variableId, operator, value]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [calculate]
variableId:
type: string
format: cuid2
description: Variable id for `calculate`.
operator:
type: string
enum: [assign, concat, add, subtract, multiply, divide]
value:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyRequireAnswerAction:
type: object
description: Requires an element/question to be answered before continuing.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [requireAnswer]
target:
type: string
description: Target element id.
additionalProperties: false
SurveyJumpToBlockAction:
type: object
description: Jumps to another block or ending when the logic rule matches.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [jumpToBlock]
target:
type: string
format: cuid2
description: Target block id or ending id.
additionalProperties: false
SurveyResource:
type: object
required:
- id
- workspaceId
- createdAt
- updatedAt
- name
- type
- status
- metadata
- defaultLanguage
- languages
- welcomeCard
- blocks
- endings
- hiddenFields
- variables
properties:
id: { type: string }
workspaceId: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
name: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
metadata:
type: object
nullable: true
additionalProperties: true
defaultLanguage:
type: string
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
languages:
type: array
items:
$ref: "#/components/schemas/SurveyLanguage"
welcomeCard:
$ref: "#/components/schemas/SurveyWelcomeCard"
blocks:
type: array
items:
$ref: "#/components/schemas/SurveyBlock"
endings:
type: array
items:
$ref: "#/components/schemas/SurveyEnding"
hiddenFields:
$ref: "#/components/schemas/SurveyHiddenFields"
variables:
type: array
items:
$ref: "#/components/schemas/SurveyVariable"
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
@@ -335,3 +1168,6 @@ components:
properties:
name: { type: string }
reason: { type: string }
identifier:
type: string
description: Optional canonical identifier related to the invalid value, such as a normalized locale code.
+1
View File
@@ -30,6 +30,7 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"generate": "turbo run generate",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test --no-cache",
"test:coverage": "turbo run test:coverage --no-cache",
"test:e2e": "playwright test",
+1
View File
@@ -30,6 +30,7 @@
"lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/ai.json",
"typecheck": "tsc --noEmit",
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
+1
View File
@@ -30,6 +30,7 @@
"lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/cache.json",
"typecheck": "tsc --noEmit",
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
+1
View File
@@ -46,6 +46,7 @@
"generate": "prisma generate",
"lint": "eslint ./src --fix",
"generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts",
"typecheck": "tsc --noEmit",
"create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts"
},
"dependencies": {
+1 -1
View File
@@ -5,5 +5,5 @@
},
"exclude": ["node_modules", "dist"],
"extends": "@formbricks/config-typescript/node16.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "tsup.config.ts"]
"include": ["src/**/*.ts", "types/**/*.ts", "zod/**/*.ts", "migration/**/*.ts", "vite.config.ts"]
}
+2 -1
View File
@@ -8,7 +8,8 @@
"types": "src/index.ts",
"scripts": {
"dev": "email dev --port 3456",
"build": "tsc --noEmit",
"build": "pnpm typecheck",
"typecheck": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.tsx",
"clean": "rimraf .turbo node_modules dist"
},
+1
View File
@@ -28,6 +28,7 @@
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
+1
View File
@@ -36,6 +36,7 @@
"build": "vite build",
"build:dev": "vite build --mode dev",
"go": "vite build --watch --mode dev",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"clean": "rimraf .turbo node_modules dist coverage",
"test": "vitest run",
@@ -1,2 +1,37 @@
import type { TWorkspaceStateSurvey } from "@/types/config";
export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
export const mockSurveyName = "Test Survey";
export const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
welcomeCard: {
enabled: false,
timeToFinish: false,
showResponseCount: false,
headline: { en: "Welcome" },
},
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
endings: [],
autoClose: null,
status: "inProgress",
recontactDays: null,
displayLimit: null,
displayOption: "displayMultiple",
hiddenFields: { enabled: false },
delay: 0,
workspaceOverwrites: {},
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
recaptcha: { enabled: false, threshold: 0.5 },
languages: [],
triggers: [],
displayPercentage: 100,
};
export const createMockSurvey = (id = mockSurveyId): TWorkspaceStateSurvey => ({
...mockSurvey,
id,
});
@@ -709,7 +709,10 @@ describe("time on page action handling", () => {
clearTimeOnPageTimers();
});
const createConfigWithTimeOnPageAction = (actionName: string, timeInSeconds: number) => ({
const createConfigWithTimeOnPageAction = (
actionName: string,
timeInSeconds: number
): { get: Mock; update: Mock } => ({
get: vi.fn().mockReturnValue({
workspace: {
data: {
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { SurveyStore } from "@/lib/survey/store";
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
import type { TWorkspaceStateSurvey } from "@/types/config";
import { createMockSurvey } from "@/lib/survey/tests/__mocks__/store.mock";
describe("SurveyStore", () => {
let store: SurveyStore;
@@ -27,10 +26,7 @@ describe("SurveyStore", () => {
});
test("returns current survey when set", () => {
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.setSurvey(mockSurvey);
expect(store.getSurvey()).toBe(mockSurvey);
@@ -40,10 +36,7 @@ describe("SurveyStore", () => {
describe("setSurvey", () => {
test("updates survey and notifies listeners when survey changes", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.subscribe(listener);
store.setSurvey(mockSurvey);
@@ -54,10 +47,7 @@ describe("SurveyStore", () => {
test("does not notify listeners when setting same survey", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.setSurvey(mockSurvey);
store.subscribe(listener);
@@ -70,10 +60,7 @@ describe("SurveyStore", () => {
describe("resetSurvey", () => {
test("resets survey to null and notifies listeners", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.setSurvey(mockSurvey);
store.subscribe(listener);
@@ -96,27 +83,21 @@ describe("SurveyStore", () => {
describe("subscribe", () => {
test("adds listener and returns unsubscribe function", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
const unsubscribe = store.subscribe(listener);
store.setSurvey(mockSurvey);
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TWorkspaceStateSurvey);
store.setSurvey({ ...mockSurvey, id: "updated-survey-id" });
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called after unsubscribe
});
test("multiple listeners receive updates", () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.subscribe(listener1);
store.subscribe(listener2);
@@ -69,6 +69,20 @@ describe("widget-file", () => {
configure: vi.fn(),
};
const createMockFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => ({
renderSurvey: vi.fn(),
setNonce: vi.fn(),
});
const getFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => {
const formbricksSurveys = window.formbricksSurveys;
if (!formbricksSurveys) {
throw new Error("window.formbricksSurveys is not set");
}
return formbricksSurveys;
};
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
@@ -139,10 +153,7 @@ describe("widget-file", () => {
(filterSurveys as Mock).mockReturnValue([]);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -154,7 +165,7 @@ describe("widget-file", () => {
vi.advanceTimersByTime(mockSurvey.delay * 1000);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
survey: mockSurvey,
appUrl: "https://fake.app",
@@ -302,10 +313,7 @@ describe("widget-file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -319,7 +327,7 @@ describe("widget-file", () => {
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_abc",
})
@@ -362,10 +370,7 @@ describe("widget-file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -378,7 +383,7 @@ describe("widget-file", () => {
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
@@ -422,10 +427,7 @@ describe("widget-file", () => {
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -437,7 +439,7 @@ describe("widget-file", () => {
vi.advanceTimersByTime(0);
// The contactId passed to renderSurvey should be read after the wait
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_after_identification",
})
@@ -452,10 +454,7 @@ describe("widget-file", () => {
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
await widget.renderWidget({
...mockSurvey,
@@ -467,7 +466,7 @@ describe("widget-file", () => {
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed. Skipping survey with segment filters."
);
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).not.toHaveBeenCalled();
});
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
@@ -598,8 +597,7 @@ describe("widget-file", () => {
(scriptEl.onload as () => void)();
// Set the global after script "loads" — simulates browser finishing execution
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
window.formbricksSurveys = createMockFormbricksSurveys();
// Advance one polling interval for waitForSurveysGlobal to find it
await vi.advanceTimersByTimeAsync(200);
@@ -609,8 +607,8 @@ describe("widget-file", () => {
// Run remaining timers for survey.delay setTimeout
vi.runAllTimers();
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().setNonce).toHaveBeenCalledWith("test-nonce-123");
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
appUrl: "https://fake.app",
workspaceId: "env_123",
@@ -629,13 +627,11 @@ describe("widget-file", () => {
// After the previous successful test, surveysLoadPromise holds a resolved promise.
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
// should reuse the cached promise rather than creating a new script element.
// @ts-expect-error -- cleaning up mock to force dedup path
delete window.formbricksSurveys;
const appendChildSpy = vi.spyOn(document.head, "appendChild");
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -653,7 +649,7 @@ describe("widget-file", () => {
});
expect(scriptAppendCalls.length).toBe(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
@@ -713,10 +709,7 @@ describe("widget-file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -731,7 +724,7 @@ describe("widget-file", () => {
);
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
@@ -239,7 +239,6 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
const startTime = Date.now();
const check = (): void => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
@@ -262,7 +261,6 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
};
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
return Promise.resolve(globalThis.window.formbricksSurveys);
}
@@ -300,7 +298,6 @@ let isPreloaded = false;
export const preloadSurveysScript = (appUrl: string): void => {
// Don't preload if already loaded or already preloading
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) return;
if (isPreloaded) return;
+1
View File
@@ -30,6 +30,7 @@
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"typecheck": "tsc --noEmit",
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
"test": "vitest run"
},
+1
View File
@@ -29,6 +29,7 @@
"lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"typecheck": "tsc --noEmit",
"build": "vite build && tsc --project tsconfig.build.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
+1
View File
@@ -55,6 +55,7 @@
"build": "vite build",
"build:dev": "vite build --mode dev",
"go": "vite build --watch --mode dev",
"typecheck": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
"preview": "vite preview",
"clean": "rimraf .turbo node_modules dist coverage",
@@ -5,10 +5,10 @@ import { cn } from "@/lib/utils";
export type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
export type ButtonSize = "default" | "custom" | "sm" | "lg" | "icon";
type ButtonVariantProps = {
interface ButtonVariantProps {
variant?: ButtonVariant | null;
size?: ButtonSize | null;
};
}
type ButtonVariantClassProps =
| (ButtonVariantProps & { class?: string; className?: never })
| (ButtonVariantProps & { class?: never; className?: string })
+1
View File
@@ -35,6 +35,7 @@
"build:analyze": "cross-env NODE_OPTIONS=--max-old-space-size=8192 ANALYZE=true vite build && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
"build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --mode dev && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
"go": "concurrently -n \"ESM,UMD\" \"vite build --watch --mode dev\" \"BUILD_UMD=true vite build --watch --mode dev\"",
"typecheck": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
"preview": "vite preview",
"clean": "rimraf .turbo node_modules dist",
@@ -1049,20 +1049,39 @@ export function Survey({
switch (errorType) {
case TResponseErrorCodesEnum.ResponseSendingError:
return (
<ResponseErrorComponent
responseData={responseQueue?.getUnsentData() ?? responseData}
questions={questions}
onRetry={retryResponse}
isRetrying={isRetrying}
/>
<>
{localSurvey.type !== "link" ? (
<div className="bg-survey-bg relative h-8 w-full">
<div className="flex w-full items-center justify-end">
<SurveyCloseButton
onClose={onClose}
hoverColor={styling.inputBgColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8}
/>
</div>
</div>
) : null}
<ResponseErrorComponent
responseData={responseQueue?.getUnsentData() ?? responseData}
questions={questions}
onRetry={retryResponse}
isRetrying={isRetrying}
/>
</>
);
case TResponseErrorCodesEnum.RecaptchaError:
case TResponseErrorCodesEnum.InvalidDeviceError:
return (
<>
{localSurvey.type !== "link" ? (
<div className="bg-survey-bg flex h-6 justify-end pt-2 pr-2">
<SurveyCloseButton onClose={onClose} />
<div className="bg-survey-bg relative h-8 w-full">
<div className="flex w-full items-center justify-end">
<SurveyCloseButton
onClose={onClose}
hoverColor={styling.inputBgColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8}
/>
</div>
</div>
) : null}
<ErrorComponent errorType={errorType} />
-1
View File
@@ -27,7 +27,6 @@ describe("Survey Logic", () => {
const mockSurvey: TJsWorkspaceStateSurvey = {
id: "survey1",
name: "Test Survey",
questions: [], // Deprecated - using blocks instead
blocks: [
{
+1
View File
@@ -11,6 +11,7 @@
"sideEffects": false,
"scripts": {
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"typecheck": "tsc --noEmit",
"clean": "rimraf node_modules .turbo"
},
"dependencies": {
+1 -1
View File
@@ -31,7 +31,7 @@ export const ZUserName = z
.min(1, {
error: "Name should be at least 1 character long",
})
.regex(/^[\p{L}\p{M}\s'\d-]+$/u, "Invalid name format");
.regex(/^[\p{L}\p{M} ',()\d-]+$/u, "Invalid name format");
export const ZUserEmail = z
.email({
+1
View File
@@ -12,6 +12,7 @@
"sideEffects": false,
"scripts": {
"clean": "rimraf .turbo node_modules dist",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx"
},
"devDependencies": {
+32
View File
@@ -15,6 +15,9 @@
"@formbricks/ai#test:coverage": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/ai#typecheck": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/cache#build": {
"dependsOn": ["@formbricks/logger#build"],
"outputs": ["dist/**"]
@@ -31,6 +34,9 @@
"@formbricks/cache#test:coverage": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/cache#typecheck": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/database#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "../../node_modules/.prisma/client/**"]
@@ -38,6 +44,9 @@
"@formbricks/database#lint": {
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"]
},
"@formbricks/database#typecheck": {
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#generate"]
},
"@formbricks/email#build": {
"dependsOn": ["^build"],
"outputs": []
@@ -77,6 +86,9 @@
"@formbricks/js-core#lint": {
"dependsOn": ["@formbricks/database#build"]
},
"@formbricks/js-core#typecheck": {
"dependsOn": ["@formbricks/database#build"]
},
"@formbricks/logger#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
@@ -99,6 +111,9 @@
"@formbricks/storage#test:coverage": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/storage#typecheck": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/survey-ui#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
@@ -131,6 +146,9 @@
"@formbricks/surveys#test:coverage": {
"dependsOn": ["@formbricks/survey-ui#build"]
},
"@formbricks/surveys#typecheck": {
"dependsOn": ["@formbricks/i18n-utils#build", "@formbricks/survey-ui#build"]
},
"@formbricks/web#dev": {
"cache": false,
"dependsOn": [
@@ -178,6 +196,16 @@
"@formbricks/surveys#build"
]
},
"@formbricks/web#typecheck": {
"dependsOn": [
"@formbricks/ai#build",
"@formbricks/cache#build",
"@formbricks/database#build",
"@formbricks/logger#build",
"@formbricks/storage#build",
"@formbricks/surveys#build"
]
},
"build": {
"dependsOn": ["^build"],
"env": [
@@ -394,6 +422,10 @@
},
"test:coverage": {
"outputs": []
},
"typecheck": {
"dependsOn": ["@formbricks/database#generate", "^typecheck"],
"outputs": []
}
},
"ui": "stream"