Compare commits

..

63 Commits

Author SHA1 Message Date
Dhruwang Jariwala 062a59efed fix: harden Helm release secret lookups (#8119) 2026-05-22 17:27:29 +05:30
Bhagya Amarasinghe b577c151c8 fix: harden Helm release secret lookups 2026-05-22 17:09:07 +05:30
Dhruwang Jariwala f39f12ec1c feat: [Backport] cascade delete Hub feedback records on org deletion (#8055) (#8116) 2026-05-22 16:22:43 +05:30
Dhruwang 01fb38cff8 chore: bump Hub image to 0.4.0 for tenant-scoped delete endpoint
Cascade delete in deleteOrganization calls DELETE /v1/tenants/{tenant_id}/data,
which is first available in Hub 0.4.0. Bumps the dev-compose default tag and the
Helm chart digest/tag so deployments don't silently land on the older Hub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:50 +05:30
Dhruwang 71e57947b9 fix: switch Hub purge to DELETE /v1/tenants/{tenant_id}/data
Hub#77 ships the proper tenant-data purge endpoint that nukes feedback
records, derived embeddings, and webhooks in one idempotent call. Swap
the placeholder bulkDelete cast for a direct client.delete<>() against
the new path and surface the per-resource counts.

Renames deleteFeedbackRecordsByTenant → deleteHubTenantData to reflect
that it deletes more than feedback records. Org-deletion call site and
tests updated accordingly. Best-effort error handling unchanged.
2026-05-22 16:08:49 +05:30
Dhruwang 5f6d6d53b2 feat: cascade delete Hub feedback records on org deletion (ENG-973)
Add a tenant-scoped purge wrapper in the Hub gateway and call it for
each FeedbackDirectory after an organization is deleted, so Hub records
do not become orphaned when the local cascade clears the directory rows.

Depends on a Hub-side change to accept a tenant-only bulkDelete payload;
the call is best-effort and failures are logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:49 +05:30
Anshuman Pandey b79758ee49 fix: [Backport] json payload limit (#8115)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 12:55:14 +04:00
Bhagya Amarasinghe 25b0b89e86 fix: use Valkey for bundled Helm Redis (backport #8092) (#8105) 2026-05-22 11:39:11 +05:30
Bhagya Amarasinghe f228ce7eb6 fix: address Helm Valkey review comments 2026-05-22 11:36:36 +05:30
Bhagya Amarasinghe 67ae13a61a fix: use Valkey for bundled Helm Redis 2026-05-22 11:36:31 +05:30
Johannes 70e72ab0de fix: backport removal of isAIDataAnalysisEnabled to v5 (#8112) 2026-05-21 20:38:04 +02:00
Johannes 07d1d918ba fix: backport CSAT and CES summary filter icons to 5.0 (#8058)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 17:56:52 +02:00
Bhagya Amarasinghe a98a0a8e73 fix: pin DNS and block redirects on webhook delivery (backport #8095) (#8106) 2026-05-21 19:10:59 +05:30
Bhagya Amarasinghe a5a67a05de fix: order Helm Hub migrations after Prisma (backport #8104) (#8107) 2026-05-21 19:10:32 +05:30
Bhagya Amarasinghe 4876e107f8 fix: order Helm Hub migrations after Prisma 2026-05-21 18:33:44 +05:30
Bhagya Amarasinghe 5db616ac07 fix(webhooks): ignore dispatcher cleanup failures 2026-05-21 18:33:25 +05:30
pandeymangg fa1ccdb2c3 pin DNS and block redirects on webhook delivery 2026-05-21 18:33:25 +05:30
Dhruwang Jariwala 6183ab4744 fix: backport #8101 reserved contact keys and segment errors to 5.0 (#8103) 2026-05-21 17:12:03 +05:30
Dhruwang Jariwala 431a3d8a76 fix: allow enterprise oauth display names (#8100) 2026-05-21 16:30:16 +05:30
Dhruwang Jariwala 91ab958379 fix: chart date range type switch + presets include today (backport #8096) (#8097) 2026-05-21 16:26:59 +05:30
Dhruwang Jariwala e5df832653 fix: [Backport] adds close button on response error screen (#8098) 2026-05-21 16:26:09 +05:30
Johannes 0909c38eb1 code rabbit comments
(cherry picked from commit 8fb2287ae7)
2026-05-21 12:46:03 +02:00
Johannes 08b0d95295 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994)
Block creation of reserved safe-identifier contact keys across API, SDK, and CSV flows to prevent collisions ahead of the v5.1 migration, and surface clearer personal-link/segment validation errors instead of unknown failures.

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

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

Backport of #8048 to release/5.0.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:55:59 +05:30
Javi Aguilar db0e2bb105 fix: add CSAT and CES summary filter icons (backport #8056) (#8063) 2026-05-20 15:05:47 +02:00
Johannes 419ceef413 fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 14:38:23 +02:00
178 changed files with 470 additions and 2338 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
-1
View File
@@ -5,7 +5,6 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App.tsx";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -38,17 +38,20 @@ const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]):
return result;
};
const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
// Export for testing
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
return formatArrayToRecord(responseValue, addressKeys);
};
const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
// Export for testing
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
return formatArrayToRecord(responseValue, contactInfoKeys);
};
const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
// Export for testing
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
const responseData: Record<string, any> = {};
const elements = getElementsFromBlocks(survey.blocks);
@@ -1,11 +1,10 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
+2 -11
View File
@@ -313,18 +313,9 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 404 notFound for ResourceNotFoundError", async () => {
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
+5 -4
View File
@@ -29,10 +29,11 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
});
test("returns a bad request response when the response id is missing", async () => {
@@ -245,34 +245,6 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
@@ -1,12 +1,7 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganization } from "@/lib/organization/service";
@@ -160,16 +155,6 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -16,7 +11,6 @@ import {
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -110,16 +104,6 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contact?.id ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
@@ -147,13 +131,6 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -195,19 +190,7 @@ describe("createResponse V2", () => {
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["someOtherField"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
@@ -216,7 +199,7 @@ describe("createResponse V2", () => {
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(InvalidInputError);
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -17,7 +12,6 @@ import {
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -105,16 +99,6 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contactId ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -138,13 +122,6 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -415,44 +415,6 @@ describe("withV3ApiWrapper", () => {
]);
});
test("returns 413 problem response for oversized JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{}",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
"x-request-id": "req-payload-too-large",
},
}),
{} as never
);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
code: "payload_too_large",
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
requestId: "req-payload-too-large",
status: 413,
title: "Payload Too Large",
})
);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
@@ -40,40 +40,6 @@ describe("parseAndValidateJsonBody", () => {
});
});
test("returns a payload too large response when the request body exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
body: "{}",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("payload_too_large");
expect(result.response.status).toBe(413);
await expect(result.response.json()).resolves.toEqual({
code: "payload_too_large",
message: "Payload Too Large",
details: {
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
-76
View File
@@ -1,76 +0,0 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
RequestBodyTooLargeError,
parseJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./request-body";
const createStreamingRequest = (chunks: string[]): Request =>
new Request("http://localhost/api/test", {
method: "POST",
body: new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
describe("request body parsing", () => {
test("rejects a request when content-length exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
},
body: "{}",
});
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
name: "RequestBodyTooLargeError",
});
});
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
});
test("allows a body exactly at the body limit", async () => {
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
const request = new Request("http://localhost/api/test", {
method: "POST",
body: rawBody,
});
const body = await readRequestBodyWithLimit(request);
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
expect(body).toBe(rawBody);
});
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
body: "{invalid-json",
});
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
});
test("returns an empty string for requests without a body", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
});
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
});
});
+1 -2
View File
@@ -1,6 +1,5 @@
import "server-only";
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -213,7 +212,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
+1 -53
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
export const selectDisplay = {
@@ -146,58 +146,6 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
}
);
export const getDisplayForResponseValidation = async (
displayId: string,
tx?: Prisma.TransactionClient
): Promise<{
surveyId: string;
workspaceId: string;
responseId: string | null;
contactId: string | null;
} | null> => {
validateInputs([displayId, ZId]);
const client = tx ?? prisma;
try {
const display = await client.display.findUnique({
where: { id: displayId },
select: {
surveyId: true,
contactId: true,
response: { select: { id: true } },
survey: { select: { workspaceId: true } },
},
});
if (!display) return null;
return {
surveyId: display.surveyId,
workspaceId: display.survey.workspaceId,
responseId: display.response?.id ?? null,
contactId: display.contactId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
throw error;
}
};
export const assertDisplayOwnership = async (
displayId: string,
workspaceId: string,
surveyId: string,
contactId: string | null,
tx?: Prisma.TransactionClient
): Promise<void> => {
const display = await getDisplayForResponseValidation(displayId, tx);
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
if (display.workspaceId !== workspaceId)
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
if (display.surveyId !== surveyId)
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
if (display.contactId !== null && display.contactId !== contactId)
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
};
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
@@ -3,18 +3,14 @@ import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
assertDisplayOwnership,
getDisplayCountBySurveyId,
getDisplayForResponseValidation,
getDisplaysByContactId,
getDisplaysBySurveyIdWithContact,
} from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
const mockResponseId = "clqnfg59i000208i426pb4wcv";
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
const mockDisplaysForContact = [
@@ -294,96 +290,3 @@ describe("getDisplaysBySurveyIdWithContact", () => {
});
});
});
const mockDisplayRecord = {
surveyId: mockSurveyId,
contactId: null as string | null,
response: null as { id: string } | null,
survey: { workspaceId: mockWorkspaceId },
};
describe("getDisplayForResponseValidation", () => {
test("returns null when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toBeNull();
});
test("returns mapped shape when display is found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
response: { id: mockResponseId },
} as any);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toEqual({
surveyId: mockSurveyId,
workspaceId: mockWorkspaceId,
responseId: mockResponseId,
contactId: mockContactId,
});
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
vi.mocked(prisma.display.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
});
});
describe("assertDisplayOwnership", () => {
test("throws InvalidInputError when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when workspaceId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when surveyId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when display is already linked to a response", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
response: { id: mockResponseId },
} as any);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when contactId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: "contact-a",
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
).rejects.toThrow(InvalidInputError);
});
test("resolves without error when all ownership checks pass", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
).resolves.toBeUndefined();
});
});
-31
View File
@@ -1,7 +1,6 @@
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 { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -325,35 +324,5 @@ describe("updateResponse", () => {
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
});
});
-8
View File
@@ -3,7 +3,6 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -570,13 +569,6 @@ export const updateResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Response", responseId);
}
throw new DatabaseError(error.message);
}
@@ -38,50 +38,6 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -104,54 +60,4 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p }));
const buffer = convertToXlsxBuffer(["name"], rows);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
payloads.forEach((p, i) => {
const cell = sheet[`A${i + 2}`]; // row 1 is header
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${p}`);
});
});
test("should defang formula injection in xlsx header names", () => {
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const headerCell = sheet["A1"];
expect(headerCell.f).toBeUndefined();
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
@@ -165,42 +165,6 @@ describe("apiWrapper", () => {
});
});
test("should handle oversized JSON input in request body", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: "{}",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 413 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "payload_too_large",
details: [
{
field: "body",
issue: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
],
});
});
test("should handle empty body when body schema is provided", async () => {
const request = new Request("http://localhost", {
method: "POST",
@@ -85,18 +85,6 @@ describe("utils", () => {
expect(body.error.message).toBe("Conflict");
});
test('return payload too large response for "payload_too_large" error', async () => {
const details = [{ field: "body", issue: "Request body must not exceed 2097152 bytes" }];
const error: ApiErrorResponseV2 = { type: "payload_too_large", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(413);
const body = await response.json();
expect(body.error.code).toBe(413);
expect(body.error.message).toBe("Payload Too Large");
expect(body.error.details).toEqual(details);
});
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
const details = [{ field: "data", issue: "malformed" }];
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
@@ -1,96 +0,0 @@
import { describe, expect, test } from "vitest";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "./date-presets";
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
});
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
describe("expandPresetDateRanges", () => {
test("includes today for 'last 7 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
});
test("includes today for 'last 30 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
});
test("expands 'today' to today..today", () => {
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
});
test("expands 'yesterday' to yesterday..yesterday", () => {
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
});
test("'this month' runs from the 1st through today", () => {
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
});
test("'last month' is the full previous calendar month", () => {
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
});
test("'last month' handles year rollover", () => {
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
});
test("'this quarter' starts at the first day of the calendar quarter", () => {
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
});
test("'this year' starts on Jan 1", () => {
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
});
test("leaves explicit [start, end] tuple unchanged", () => {
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
});
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
});
test("returns input unchanged when there are no time dimensions", () => {
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
});
test("preserves other timeDimension fields (granularity, dimension)", () => {
const q: TChartQuery = {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
],
};
const result = expandPresetDateRanges(q, NOW);
expect(result.timeDimensions?.[0]).toMatchObject({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: ["2026-05-15", "2026-05-21"],
});
});
test("does not mutate the input query", () => {
const q = queryWithDateRange("last 7 days");
const before = JSON.stringify(q);
expandPresetDateRanges(q, NOW);
expect(JSON.stringify(q)).toBe(before);
});
});
@@ -1,5 +1,3 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -8,17 +6,6 @@ import {
getFilterOperatorsForType,
} from "./schema-definition";
const chartCubeSchemaPath = fileURLToPath(
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
);
const dockerCubeSchemaPath = fileURLToPath(
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
);
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
describe("schema-definition", () => {
describe("getFilterOperatorsForType", () => {
test("returns string operators", () => {
@@ -107,20 +94,5 @@ describe("schema-definition", () => {
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
test("only exposes members present in the deployed Cube schema", () => {
const chartCubeSchema = readChartCubeSchema();
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
getCubeMemberName(id)
);
for (const member of exposedMembers) {
expect(chartCubeSchema).toContain(` ${member}: {`);
}
});
test("keeps the Helm and Docker Cube schemas in sync", () => {
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
});
});
});
-43
View File
@@ -5,7 +5,6 @@ import {
createFeedbackRecord,
createFeedbackRecordsBatch,
deleteFeedbackRecord,
deleteHubTenantData,
getFeedbackRecordTenant,
listFeedbackRecords,
retrieveFeedbackRecord,
@@ -345,48 +344,6 @@ describe("hub service", () => {
});
});
describe("deleteHubTenantData", () => {
test("returns config error when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await deleteHubTenantData("tenant-1");
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns mapped data when client.delete resolves", async () => {
const deleteSpy = vi.fn().mockResolvedValue({
tenant_id: "tenant-1",
deleted_feedback_records: 3,
deleted_embeddings: 5,
deleted_webhooks: 1,
});
vi.mocked(getHubClient).mockReturnValue({ delete: deleteSpy } as any);
const result = await deleteHubTenantData("tenant-1");
expect(deleteSpy).toHaveBeenCalledWith("/v1/tenants/tenant-1/data");
expect(result.error).toBeNull();
expect(result.data).toEqual({
deletedFeedbackRecords: 3,
deletedEmbeddings: 5,
deletedWebhooks: 1,
});
});
test("returns error when client.delete throws", async () => {
vi.mocked(getHubClient).mockReturnValue({
delete: vi.fn().mockRejectedValue(new Error("network")),
} as any);
const result = await deleteHubTenantData("tenant-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 0, message: "network" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
@@ -17,7 +17,7 @@ interface TemplateTagsProps {
type NonNullabeChannel = NonNullable<TWorkspaceConfigChannel>;
const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
export const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
switch (role) {
case "productManager":
return "border-blue-300 bg-blue-50 text-blue-500";
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -123,7 +123,11 @@ export const SurveyLoadingAnimation = ({
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
)}>
{isBrandingEnabled && (
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
<Image
src={Logo as string}
alt="Logo"
className={cn("w-32 transition-all duration-1000 md:w-40")}
/>
)}
<LoadingSpinner />
</div>
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
<DropdownMenuPrimitive.SubContent
ref={ref as any}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
className
)}
{...props}
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
className
)}
{...props}
@@ -26,4 +26,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge };
export { Badge, badgeVariants };
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
className
)}
{...props}
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
className
)}
{...props}
-2
View File
@@ -10,8 +10,6 @@
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
"build:dev": "pnpm run build",
"start": "next start",
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
-8
View File
@@ -1,8 +0,0 @@
import "@prisma/client";
declare module "@prisma/client" {
namespace Prisma {
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"exclude": [
"../../.env",
".next",
"node_modules",
"playwright",
"**/*.test.ts",
"**/*.test.tsx",
"**/tests/**",
"**/__mocks__/**",
"**/__tests__/**"
],
"extends": "./tsconfig.json",
"include": [
"next-env.d.ts",
"**/*.d.ts",
"app/**/*.ts",
"app/**/*.tsx",
"lib/**/*.ts",
"lib/**/*.tsx",
"modules/**/*.ts",
"modules/**/*.tsx",
"scripts/**/*.ts",
"../../packages/types/*.d.ts"
]
}
+2 -3
View File
@@ -65,7 +65,6 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
When the Formbricks migration job is enabled, Hub waits for the `formbricks-migration` Job to complete before its own goose/river init migrations run. This keeps fresh shared-database installs from creating Hub tables before Prisma has initialized the Formbricks schema.
If the Job has already been cleaned up, Hub only continues after all expected Prisma and data migration success markers are present in the database.
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
@@ -130,7 +129,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
| deployment.env | object | `{}` | |
| deployment.envFrom | string | `nil` | |
| deployment.image.digest | string | `""` | When set, takes precedence over tag. |
| deployment.image.digest | string | `""` | |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.image.tag | string | `""` | |
@@ -225,7 +224,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| hub.migration.waitForFormbricksMigration.enabled | bool | `true` | |
| hub.migration.waitForFormbricksMigration.intervalSeconds | int | `5` | |
| hub.migration.waitForFormbricksMigration.maxAttempts | int | `180` | |
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | Consecutive missing Job reads before using DB markers. |
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | |
| hub.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
+1 -1
View File
@@ -1,6 +1,6 @@
{{ .Release.Name | camelcase }} with {{ include "formbricks.deploymentImage" . }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
{{ .Release.Name | camelcase }} with {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
Here's how you can access and manage your deployment:
---
+4 -15
View File
@@ -125,6 +125,10 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
{{- end }}
{{- define "formbricks.migrationJobName" -}}
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisName" -}}
{{- .Values.redis.fullnameOverride | default (printf "%s-redis" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}}
{{- end }}
@@ -153,21 +157,6 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- .Values.redis.auth.existingSecretPasswordKey | default "REDIS_PASSWORD" -}}
{{- end }}
{{- define "formbricks.migrationJobName" -}}
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{/*
Formbricks application image reference. A configured digest takes precedence over the tag.
*/}}
{{- define "formbricks.deploymentImage" -}}
{{- if .Values.deployment.image.digest -}}
{{- printf "%s@%s" .Values.deployment.image.repository .Values.deployment.image.digest -}}
{{- else -}}
{{- printf "%s:%s" .Values.deployment.image.repository (.Values.deployment.image.tag | default .Chart.AppVersion | default "latest") -}}
{{- end -}}
{{- end }}
{{- define "formbricks.hubSecretName" -}}
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
{{- end }}
+1 -1
View File
@@ -79,7 +79,7 @@ spec:
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds | default 30 }}
containers:
- name: {{ template "formbricks.name" . }}
image: {{ include "formbricks.deploymentImage" . }}
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
{{- if .Values.deployment.command }}
command:
+11 -194
View File
@@ -39,35 +39,22 @@ spec:
initContainers:
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
- name: wait-for-formbricks-migration
image: {{ include "formbricks.deploymentImage" . }}
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
command:
- node
- -e
- |
const fs = require("fs");
const fsp = fs.promises;
const https = require("https");
const path = require("path");
const { pathToFileURL } = require("url");
const { Prisma, PrismaClient } = require("@prisma/client");
const parsePositiveInteger = (value, fallback) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const maxAttempts = parsePositiveInteger(process.env.FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS, 180);
const missingJobMaxAttempts = parsePositiveInteger(
process.env.FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS,
12
);
const intervalSeconds = parsePositiveInteger(
process.env.FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS,
5
const maxAttempts = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS || "180", 10);
const missingJobMaxAttempts = Number.parseInt(
process.env.FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS || "12",
10
);
const intervalSeconds = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS || "5", 10);
const jobName = process.env.FORMBRICKS_MIGRATION_JOB_NAME;
const migrationsDir = path.resolve("packages/database/dist/migration");
const prisma = new PrismaClient();
const namespace = fs
.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8")
.trim();
@@ -115,125 +102,10 @@ spec:
request.end();
});
const loadExpectedMigrationMarkers = async () => {
const entries = await fsp.readdir(migrationsDir, { withFileTypes: true });
const schemaMigrationNames = [];
const dataMigrationIds = [];
const migrationEntries = entries
.filter((dirent) => dirent.isDirectory())
.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of migrationEntries) {
const migrationPath = path.join(migrationsDir, entry.name);
const files = await fsp.readdir(migrationPath);
if (files.includes("migration.sql")) {
schemaMigrationNames.push(entry.name);
}
if (files.includes("migration.js")) {
const migrationModule = await import(
pathToFileURL(path.join(migrationPath, "migration.js")).href
);
for (const exportedValue of Object.values(migrationModule)) {
if (
exportedValue &&
typeof exportedValue === "object" &&
exportedValue.type === "data" &&
exportedValue.id
) {
dataMigrationIds.push(exportedValue.id);
}
}
}
}
return { schemaMigrationNames, dataMigrationIds };
};
let expectedMigrationMarkersPromise;
const getExpectedMigrationMarkers = () => {
if (!expectedMigrationMarkersPromise) {
expectedMigrationMarkersPromise = loadExpectedMigrationMarkers();
}
return expectedMigrationMarkersPromise;
};
const hasFormbricksMigrationSuccessMarkers = async () => {
try {
const { schemaMigrationNames, dataMigrationIds } = await getExpectedMigrationMarkers();
// apply-migrations.js persists success in these DB tables after Prisma/data migrations complete.
if (schemaMigrationNames.length === 0) {
console.log(
`No schema migrations found in ${migrationsDir}; refusing missing-Job success fallback.`
);
return false;
}
const appliedSchemaMigrations = await prisma.$queryRaw`
SELECT migration_name
FROM _prisma_migrations
WHERE finished_at IS NOT NULL
AND rolled_back_at IS NULL
AND migration_name IN (${Prisma.join(schemaMigrationNames)})
`;
const appliedSchemaMigrationNames = new Set(
appliedSchemaMigrations.map((migration) => migration.migration_name)
);
const missingSchemaMigrations = schemaMigrationNames.filter(
(migrationName) => !appliedSchemaMigrationNames.has(migrationName)
);
if (missingSchemaMigrations.length > 0) {
console.log(
`Prisma migration markers are incomplete; ${missingSchemaMigrations.length} schema migration(s) are missing.`
);
return false;
}
if (dataMigrationIds.length === 0) {
return true;
}
const appliedDataMigrations = await prisma.$queryRaw`
SELECT id
FROM "DataMigration"
WHERE status = 'applied'
AND id IN (${Prisma.join(dataMigrationIds)})
`;
const appliedDataMigrationIds = new Set(
appliedDataMigrations.map((migration) => migration.id)
);
const missingDataMigrations = dataMigrationIds.filter(
(migrationId) => !appliedDataMigrationIds.has(migrationId)
);
if (missingDataMigrations.length > 0) {
console.log(
`Data migration markers are incomplete; ${missingDataMigrations.length} data migration(s) are missing.`
);
return false;
}
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`Migration success markers are not ready: ${message}`);
return false;
}
};
(async () => {
let missingJobAttempts = 0;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const job = await fetchJob();
missingJobAttempts = 0;
const conditions = job.status?.conditions || [];
const isComplete = conditions.some(
(condition) => condition.type === "Complete" && condition.status === "True"
@@ -256,30 +128,12 @@ spec:
console.log(`Waiting for ${jobName} to complete (${attempt}/${maxAttempts})...`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (error && error.statusCode === 404) {
missingJobAttempts += 1;
if (missingJobAttempts >= missingJobMaxAttempts) {
const hasSuccessMarkers = await hasFormbricksMigrationSuccessMarkers();
if (hasSuccessMarkers) {
console.log(
`${jobName} was not found after ${missingJobAttempts} consecutive attempts, ` +
"but all Formbricks migration success markers are present; starting Hub migrations."
);
return;
}
}
console.log(
`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}; missing ${missingJobAttempts}/${missingJobMaxAttempts}): ${message}`
);
} else {
missingJobAttempts = 0;
console.log(
`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`
);
if (error && error.statusCode === 404 && attempt >= missingJobMaxAttempts) {
console.log(`${jobName} was not found after ${attempt} attempts; assuming it was already cleaned up.`);
return;
}
console.log(`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`);
}
await sleep(intervalSeconds * 1000);
@@ -291,45 +145,8 @@ spec:
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect().catch((error) => {
console.error(error);
});
});
{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
envFrom:
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
- secretRef:
name: {{ template "formbricks.name" . }}-app-secrets
{{- end }}
{{- range $value := .Values.deployment.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- if (eq .type "secret") }}
- secretRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
env:
{{- range $key, $value := .Values.deployment.env }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
- name: FORMBRICKS_MIGRATION_JOB_NAME
value: {{ include "formbricks.migrationJobName" . | quote }}
- name: FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS
@@ -51,5 +51,5 @@ roleRef:
subjects:
- kind: ServiceAccount
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
namespace: {{ .Release.Namespace }}
namespace: {{ include "formbricks.namespace" . }}
{{- end }}
@@ -46,7 +46,7 @@ spec:
{{- end }}
containers:
- name: migration
image: {{ include "formbricks.deploymentImage" . }}
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
command:
- node
-1
View File
@@ -939,7 +939,6 @@ hub:
waitForFormbricksMigration:
enabled: true
maxAttempts: 180
# Consecutive missing Job reads before falling back to persisted Prisma/DataMigration markers.
missingJobMaxAttempts: 12
intervalSeconds: 5
+295 -730
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

-56
View File
@@ -1,56 +0,0 @@
---
title: "AI Features"
description: "How AI features are organized, hosted, and controlled in Formbricks."
icon: "sparkles"
---
<Note>
AI features are part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
Formbricks ships a single organization-wide toggle that turns on AI-powered helpers across the app:
**Settings → Organization → General → Smart functionality (AI)**.
## AI Principles
1. **Always optional**: AI is disabled until your organization enables. You can run
Formbricks fully without AI.
2. **We separate AI**: We distinguish between:
- **Smart Functionality**: helps teams build and operate faster (for example
[AI Survey Translation](/surveys/general-features/multi-language-surveys#translate-with-ai) or [AI Chart Creation](/unify-feedback/dashboards-charts#ai-builder)).
- **Data AI**: features that work directly with your feedback data (creating embeddings for Unify Feedback semantic search).
### Privacy-first and self-hosted where possible
We prioritize self-hosted AI, especially for capabilities that process customer feedback data. Formbricks supports
AI in self-hosted and on-premise environments, and we prefer open-weight models whenever feasible.
For Unify Feedback semantic search, we host the embeddings model ourselves. This means feedback data is not shared
with third-party model providers for that capability, and your collected feedback/response data is never used as AI
training input.
## Model Hosting Status
- **Current Smart Functionality model**: Gemini 3.5 Flash hosted on Google Cloud Platform in Germany.
- **Current Embeddings model**: Alibaba GTE embeddings hosted by Formbricks in Germany.
- **In progress**: evaluation of self-hosted Kimi 2.5 to replace Gemini 3.5 Flash for Smart Functionality.
## AI Features by Category
### Smart Functionality
- **[AI Survey Translation](/surveys/general-features/multi-language-surveys#translate-with-ai)**:
auto-translate survey questions, options, and prompts into enabled languages.
- **[AI Chart Creation](/unify-feedback/dashboards-charts#ai-builder)**:
describe a chart in natural language and Formbricks generates the underlying query.
### Data AI
- **Embeddings creation**:
create embeddings for feedback records so they can be used for semantic search, clustering, and retrieval.
## Permissions
Only **Owners** and **Managers** can change the AI toggle. Other roles see a read-only state.
@@ -1,10 +0,0 @@
---
title: "AI Features"
description: "Enable AI-powered helpers like survey translation and AI chart creation."
icon: "sparkles"
sidebarTitle: "AI Features"
---
A single organization toggle unlocks AI-assisted survey translation and AI chart creation across the app. Requires `AI_PROVIDER`, `AI_MODEL`, and the matching provider credentials on the instance.
Read the full guide: [AI Features](/platform/features/ai-features).
@@ -1,10 +0,0 @@
---
title: "Dashboards & Charts"
description: "Visualize Feedback Records with charts and group them onto shareable dashboards."
icon: "chart-line"
sidebarTitle: "Dashboards & Charts"
---
Build Area, Bar, Line, Pie, and Big Number charts on top of any Feedback Directory, then arrange them on dashboards to share with your team.
Read the full guide: [Dashboards & Charts](/unify-feedback/dashboards-charts).
@@ -1,10 +0,0 @@
---
title: "Unify Feedback"
description: "Consolidate feedback from surveys, CSVs, and APIs into one normalized store."
icon: "layer-group"
sidebarTitle: "Unify Feedback"
---
Unify Feedback brings survey responses, CSV uploads, and API-ingested records into the same normalized model under organization-scoped Feedback Directories. Workspaces can be granted access to specific directories.
Read the full guide: [Unify Feedback overview](/unify-feedback/overview).
@@ -30,7 +30,7 @@ This confirms the Google identity for the current deletion attempt, but it does
### How to connect your Formbricks instance to Google
{/* prettier-ignore-start */}
<!-- prettier-ignore-start -->
<Steps>
<Step title="Create a GCP Project">
@@ -95,4 +95,4 @@ This confirms the Google identity for the current deletion attempt, but it does
</Step>
</Steps>
{/* prettier-ignore-end */}
<!-- prettier-ignore-end -->
@@ -15,7 +15,7 @@ These variables are present inside your machine's docker-compose file. Restart t
For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` together with Google Cloud credentials. Formbricks uses Google Cloud naming here, even though the underlying SDK still talks to Vertex AI endpoints for Gemini model access.
{/* prettier-ignore-start */}
<!-- prettier-ignore-start -->
| Variable | Description | Required | Default |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -144,6 +144,6 @@ For Helm deployments, the chart deploys Cube by default (`cube.enabled: true`).
cluster instead, set `cube.enabled: false`, point `CUBEJS_API_URL` at your endpoint, and supply
`CUBEJS_API_SECRET` through your existing secret management setup.
{/* prettier-ignore-end */}
<!-- prettier-ignore-end -->
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.
-36
View File
@@ -1,36 +0,0 @@
---
title: "Surveys"
description: "Design, distribute, and analyze surveys with Formbricks."
icon: "tablet-screen"
---
Formbricks surveys let you collect feedback from customers, users, and employees through link surveys, website surveys, and in-app surveys.
## Build
A drag-and-drop builder with conditional logic, recall, variables, hidden fields, multi-language support, and over a dozen question types. Surveys can be fully styled to match your brand.
## Distribute
Share surveys via a public link, embed them in your website, or trigger them in your web or mobile app. Use targeting, recontact rules, and quotas to control who sees a survey and when.
## Analyze
Each survey has a built-in summary view and response table. For cross-survey analytics, pipe responses into [Dashboards & Charts](/unify-feedback/dashboards-charts) to build custom visualizations and KPIs. Export to CSV/XLSX, or stream responses out via webhooks and the REST API.
## Next steps
<CardGroup cols={2}>
<Card title="Link Surveys" icon="link" href="/surveys/link-surveys/quickstart">
Share standalone survey links by email, chat, or QR code.
</Card>
<Card title="Website & App Surveys" icon="mobile" href="/surveys/website-app-surveys/quickstart">
Trigger surveys inside your website or app.
</Card>
<Card title="Question Types" icon="question" href="/surveys/question-type/free-text">
Browse every available question type.
</Card>
<Card title="Best Practices" icon="lightbulb" href="/surveys/best-practices/understanding-survey-types">
Templates and proven survey patterns.
</Card>
</CardGroup>
-55
View File
@@ -1,55 +0,0 @@
---
title: "Dashboards & Charts"
description: "Visualize Feedback Records and group charts onto shareable dashboards."
icon: "chart-line"
---
Dashboards & Charts let you turn Feedback Records into visual analytics. A **Chart** is a single visualization scoped to one Feedback Directory. A **Dashboard** is a grid of charts you can share with your team.
## Charts
Charts live under **Workspace → Charts**. Each chart is a query plus a visualization config.
### Available chart types
| Type | When to use |
| --- | --- |
| **Area Chart** | Trend over time with magnitude emphasis (cumulative volume, NPS trend with shaded area). Default. |
| **Bar Chart** | Compare a measure across categories (responses per source, NPS by segment). |
| **Line Chart** | Trend over time without area fill (CSAT week-over-week, response rate). |
| **Pie Chart** | Share-of-total across a small set of categories (channel mix, sentiment split). |
| **Big Number** | Headline KPI for a dashboard (total responses, avg NPS, % positive). |
You can build a chart in two ways:
### Manual builder
Pick a Feedback Directory, choose dimensions and measures (count of records, average NPS, ...), apply filters, and select a chart type. Live preview updates as you tweak.
### AI builder
Describe what you want in natural language ("Average NPS by month over the last 90 days") and Formbricks generates the Cube query for you. You can edit the result in the manual builder afterwards.
The AI builder requires **Smart functionality (AI)** to be enabled at the organization level and a configured AI provider. See [AI Features](/platform/features/ai-features).
## Dashboards
Dashboards live under **Workspace → Dashboards**. Each dashboard is a grid you can resize and arrange.
From a dashboard you can:
- **Create** a new chart inline and have it added automatically.
- **Add existing** charts.
- **Reorder and resize** widgets.
- **Duplicate** a chart for a quick variation.
- **Delete** a widget (the underlying chart stays in the workspace).
## Permissions
- Owners, Managers, and members with **Manage** or **Read & Write** access can create and edit dashboards and charts.
- Members with **Read** access can view dashboards and charts but cannot edit them.
## Requirements
- A Feedback Directory with records.
- Workspace access to that directory.
@@ -1,31 +0,0 @@
---
title: "Feedback Directories"
description: "Org-level containers that group related Feedback Records and their sources."
icon: "folder-tree"
---
A **Feedback Directory** is the top-level container for feedback inside an organization. Every Feedback Record belongs to exactly one directory, and every source writes into a single directory.
## When to create a new directory
Create one directory per logically separate dataset. Common patterns:
- **By product line** (e.g. "Mobile devices", "Web apps")
- **By stakeholder group** (e.g. "Customers", "Employees")
- **By region** (e.g. "Europe", "North America")
## Workspace access
Directories live at the **organization** level but are exposed to **workspaces** through an access list. Each workspace can **only access one directory.**
Manage directory access from **Organization Settings → Feedback Directories**:
- Create new directories
- Rename or archive directories
- Add or remove workspace access
Only **Owners** and **Managers** can manage directories. Workspace members see the directories their workspace has access to inside the Unify section.
## Archiving
Archiving a directory hides it from default views but does not delete its records. Use it for one-off programs that have ended.
-50
View File
@@ -1,50 +0,0 @@
---
title: "Feedback Records"
description: "The normalized unit of feedback inside a Feedback Directory."
icon: "list-check"
---
A **Feedback Record** is one piece of feedback expressed in a normalized schema. Whether it came from a survey response, a CSV row, or an API push, it lands in the same shape so you can query everything together.
## The Feedback Record schema
Every record has the following fields. Required fields must be mapped by every source.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `submission_id` | string | Yes | Stable ID for the submission (e.g. `response_id`, `ticket_id`, `order_id`). Used for idempotent re-imports. |
| `collected_at` | timestamp | Yes | When the feedback was originally collected. |
| `source_type` | string | Yes | The kind of source (e.g. `survey`, `csv`, `review`). |
| `field_id` | string | Yes | Stable identifier for the question/field. |
| `field_type` | enum | Yes | One of `text`, `categorical`, `nps`, `csat`, `ces`, `rating`, `number`, `boolean`, `date`. |
| `tenant_id` | string | No | Feedback Directory ID. Set automatically when ingesting. |
| `source_id` | string | No | Reference to the survey/form/ticket/review ID. |
| `source_name` | string | No | Human-readable source name for display. |
| `field_label` | string | No | The question text or field label. |
| `field_group_id` | string | No | Groups related fields (matrix, ranking, grid questions). |
| `field_group_label` | string | No | Human-readable group label. |
| `value_text` | string | No | Text responses. |
| `value_number` | float64 | No | Numeric responses (ratings, NPS, CSAT). |
| `value_boolean` | boolean | No | Yes/no responses. |
| `value_date` | timestamp | No | Date responses. |
| `metadata` | jsonb | No | Free-form context (device, campaign, custom fields). |
| `language` | string | No | ISO 639-1 language code (`en`, `de`, `fr`, ...). |
| `user_id` | string | No | Anonymous user ID. Never store PII here. |
The right `value_*` field is set based on `field_type`. For example a `nps` field uses `value_number`, an open-text comment uses `value_text`.
## Viewing and managing records
Inside a workspace, navigate to **Unify → Feedback Records**. You'll see the latest records across every directory the workspace has access to, sorted by `collected_at`.
From the table you can:
- **Filter** by directory, source, field type, or date range.
- **Open** a record drawer to see the full field set and metadata.
- **Edit** values inline for cleanup (e.g. relabel a categorical answer).
- **Delete** a record.
- **Add** a record manually via the "+ Add" button.
## Idempotent imports
Sources that re-ingest data (CSV uploads, API ingestions) use `submission_id` as the dedup key. Re-importing the same `submission_id` updates the existing record instead of creating a duplicate.
-54
View File
@@ -1,54 +0,0 @@
---
title: "Feedback Sources"
description: "Sources that bring feedback data into a Feedback Directory."
icon: "plug"
---
A **Source** defines how external data is mapped into Feedback Records inside a Feedback Directory. Manage them from **Unify → Sources**.
## Source types
Formbricks supports three source types:
### 1. Formbricks Surveys
Pipe responses from a Formbricks survey directly into a Feedback Directory. Pick a survey, select the questions you want to ingest - that's it. Formbricks automatically maps each question to its `field_type`. Optionally create Feedback Records of existing responses on connect.
### 2. CSV Import
Upload a CSV (up to **2 MB** and **1,000 rows**) and Formbricks auto-suggests a column mapping based on common header names (`timestamp`, `response_id`, `rating`, `feedback_text`, ...). Required columns: `submission_id`, `field_id`, `field_type`, and the feedback value.
<Note>
Re-uploading a CSV with the same `submission_id` updates existing records instead of creating duplicates.
</Note>
A sample CSV is available from the source creation dialog.
### 3. API Ingestion
Push records into a directory programmatically from your own systems. Best for server-to-server ingestion. API reference docs are coming soon.
## Field mapping
For CSV and API sources, you map each source column or question to a Feedback Record field. CSV mapping suggests matches automatically with high/medium/low confidence based on header names.
For Formbricks Surveys, we handle the mapping internally - each question type is translated into the matching Hub `field_type`:
- Single-select, multi-select, dropdown → `categorical`
- NPS → `nps`
- Rating → `rating`
- CSAT → `csat`
- CES → `ces`
- Free text → `text`
- Number → `number`
- Date → `date`
- Boolean / consent → `boolean`
## Managing sources
From the Sources page you can:
- **Create** a new source for any source type.
- **Edit** the mapping for an existing source.
- **Pause** or **resume** ingestion.
- **Delete** a source. Existing records stay in the directory.
-15
View File
@@ -1,15 +0,0 @@
---
title: "Formbricks Hub"
description: "The data layer that powers Unify Feedback."
icon: "database"
---
**Formbricks Hub** is Formbricks' unified feedback data layer. It stores normalized feedback from multiple input channels so teams can query, analyze, and act on it in one place.
Unify Feedback is powered by Hub under the hood: Feedback Sources ingest data into Hub, Feedback Records are stored in Hub, and dashboards, charts, and topics build on that shared model.
Hub is built for **the age of AI**: each open-text feedback record is vectorized so semantic search, clustering, and retrieval work out of the box. Its event-based architecture also lets you enrich records with any model, provider, and custom metadata of your choice.
The Hub is fully open-source (Apache 2.0) and can be self-hosted.
To learn more, visit the Hub docs at [hub.formbricks.com](https://hub.formbricks.com) and the [Hub API Reference](https://hub.formbricks.com/api).
-38
View File
@@ -1,38 +0,0 @@
---
title: "Unify Feedback"
description: "Bring feedback from every source into one place and turn it into insights."
icon: "layer-group"
---
Unify Feedback is the part of Formbricks that consolidates feedback from across your stack into a single, queryable store. Survey responses, CSV imports, API ingestions, and tool-generated records all land in the same model so you can analyze them together.
## Why Unify Feedback
Most companies collect feedback in many places: surveys, support tickets, app store reviews, NPS tools, sales calls. Each lives in its own silo with its own schema. Unify Feedback normalizes all of these into **Feedback Records** grouped under **Feedback Directories**, so they can be filtered, visualized, and acted on as one dataset.
## How it works
1. **Create a Feedback Directory.** A directory is a tenant-scoped bucket for related feedback (for example, "Product Feedback" or "Support 2026").
2. **Connect Sources.** Pull data from Formbricks surveys, upload CSVs, or push records via the API.
3. **Explore Records.** Browse, filter, edit, and tag individual Feedback Records.
4. **Discover Topics.** Use vector based Topics & Subtopics (Preview) to cluster open-text feedback.
5. **Visualize.** Build Charts and group them on Dashboards to share insights with your team.
<CardGroup cols={2}>
<Card title="Feedback Directories" icon="folder-tree" href="/unify-feedback/feedback-directories">
Org-level buckets that group your feedback.
</Card>
<Card title="Feedback Records" icon="list-check" href="/unify-feedback/feedback-records">
The normalized unit of feedback inside a directory.
</Card>
<Card title="Feedback Sources" icon="plug" href="/unify-feedback/feedback-sources">
Connectors that bring data into a directory.
</Card>
<Card title="Dashboards & Charts" icon="chart-line" href="/unify-feedback/dashboards-charts">
Visualize and share feedback insights.
</Card>
</CardGroup>
<Note>
Unify Feedback is an enterprise feature. Enable it on Formbricks Cloud with a paid plan, or self-host with a license.
</Note>
-33
View File
@@ -1,33 +0,0 @@
---
title: "Topics & Subtopics (Preview)"
description: "Vector clustering of open-text feedback into Topics and Subtopics."
icon: "tags"
---
<Warning>
Topics & Subtopics is a **Preview** feature. The schema and UI may change.
</Warning>
Open-text feedback ("Why did you give this score?", support tickets, app reviews) is rich but hard to count. Topics & Subtopics uses AI to cluster free text into a two-level taxonomy you can filter, count, and trend over time.
## How it works
1. Pick a Feedback Directory under **Unify → Topics & Subtopics**.
2. Formbricks scans `value_text` across the directory and proposes a set of **Topics** (broad categories) and **Subtopics** (specific themes within a topic).
3. Each record can be assigned to one Topic and one Subtopic.
## What you can do today
- **Browse** the proposed Topic / Subtopic tree for a directory.
- **Inspect** which records cluster under each Topic.
## Roadmap
- Manual edits to topic labels and assignments.
- Topic filters on Charts and Dashboards.
- Per-source topic confidence scoring.
## Requirements
- A Feedback Directory with records.
- Smart functionality (AI) enabled at the organization level. See [AI Features](/platform/features/ai-features).
+29
View File
@@ -0,0 +1,29 @@
---
title: "XM & Surveys"
description: "Learn how Formbricks helps you gather, analyse, and report experience data."
icon: "tablet-screen"
---
Experience Management is the practice of measuring and managing how a stakeholder group of an organization (customers, employees, patients, citizens, etc...) experience the products or services of the organization.
Historically, Experience Management has three steps:
1. **Gather** data
2. **Analyze** and report on the data
3. **Integrate** and automate to measure experiences at scale
## Gather data
The heart of Formbricks data gathering is a powerful yet user-friendly survey builder. With a simple drag-and-drop interface, you can add questions, set response options, handle variables, set up complex logic and manage quotas. Our surveys have a modern look & feel, can be fully customized to match your brand - all while keeping respondent data safe.
## Analytics & Report
Formbricks gives you clear analytics and insights to understand user responses. It organizes survey results into easy-to-read formats, helping you spot trends, identify issues, and find opportunities for improvement. You can export your data to .csv or .xlsx or pipe it to your data lake via API.
We're working on a fully compliant way to leverage AI to harvest insights from unstructured data as well as a comprehensive reporting feature.
## Integrate & Automate
Experience Management scales best, when it is automated. Webhooks and the comprehensive REST API make it fast and easy to build integrations into your existing tech stack. Formbricks also powers integrations for n8n, ActivePieces, Zapier and Make.com to build any flow that you need.
@@ -132,30 +132,6 @@ For link surveys, the translation delivery is dependent on the `lang` URL parame
---
## Translate with AI
Translating every question, option, and label by hand can take a while. If your organization has AI enabled, you can fill in missing translations in one click.
<Steps>
<Step title="Open the Manage Translations modal">
Inside the survey editor, switch to the language you want to translate into and open the **Manage Translations** modal.
</Step>
<Step title="Click 'Translate with AI'">
The button is enabled when there are empty fields in the selected target language. Formbricks translates all empty headlines, descriptions, choices, and button labels from the default language into the target language.
</Step>
<Step title="Review and edit">
AI-translated strings are filled into the editor like manual translations. Review them before publishing and tweak anything that needs a different tone or wording.
</Step>
</Steps>
<Note>
AI translation is an [Enterprise feature](/self-hosting/advanced/license) and requires **Smart functionality (AI)** to be enabled at the organization level. See [AI Features](/platform/features/ai-features).
</Note>
---
## RTL Language Support
Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew, Persian, and Urdu. When you add an RTL language to your survey, the survey interface automatically adjusts to display content from right to left.

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