Compare commits

..

35 Commits

Author SHA1 Message Date
Matti Nannt 838b779c4b chore: address CodeRabbit review on PR #8122
- Delete duplicate getContacts happy-path test (already covered by
  "should return contacts for given workspaceIds" earlier in the file)
- Make widget setIsSurveyRunning test self-contained (toggles true then
  false) and have the "already running" branch test explicitly set
  isSurveyRunning=true at its start, removing cross-test state coupling

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:27:00 +02:00
Matti Nannt 73e2f1d259 chore: resolve blocker + clear-path high SonarQube issues
Address 23 SonarQube issues with low-risk, contained fixes:

BLOCKER (18):
- Mark CONTROL_HASH bcrypt fixtures as NOSONAR (intentional dummy
  hashes for timing-attack-safe password verification, not real hashes)
- Add missing assertions to 15 tests flagged for "no assertion"
- Remove implicit switch fallthrough in survey-bg-selector-tab

HIGH (5):
- Drop unnecessary "void" operator on synchronous saveToStorage()
- Unwrap four "await Promise.all(map => plainObject)" calls that
  wrapped non-Promise values

The remaining ~84 HIGH issues (cognitive complexity refactors and
deeply-nested function rewrites) are intentionally left for separate,
focused PRs since each requires careful per-call-site judgment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:14:42 +02:00
Bhagya Amarasinghe be5beaeed7 fix: harden Helm release secret lookups (#8118) 2026-05-22 11:54:20 +00:00
Dhruwang Jariwala bc56f99fd8 feat: cascade delete Hub feedback records on org deletion (ENG-973) (#8055)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:51:00 +00:00
Harsh Bhat 0f38627627 docs: restructure into Platform, Surveys, and Unify Feedback tabs (#8114)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-22 09:09:03 +00:00
Bhagya Amarasinghe a878bdff42 fix: limit JSON request body size (#8051)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-22 08:09:45 +00:00
Bhagya Amarasinghe d757e12c76 fix: return 404 when response is deleted mid-update (#8049) 2026-05-22 07:58:35 +00:00
Bhagya Amarasinghe 629febb2f7 fix: order Helm Hub migrations after Prisma (#8104) 2026-05-22 06:31:11 +00:00
Bhagya Amarasinghe 40b93cc834 fix: use Valkey for bundled Helm Redis (#8092) 2026-05-22 05:56:57 +00:00
Anshuman Pandey f41d2c14f1 fix: pin DNS and block redirects on webhook delivery in the response pipeline (#8095)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 04:46:20 +00:00
Matti Nannt af51414b03 fix: remove isAIDataAnalysisEnabled (ENG-1039) (#8109)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:54:36 +00:00
Matti Nannt a9e39dd4ab fix: validate displayId ownership on response creation (ENG-825) (#8046)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:11:19 +00:00
Johannes c8b0bb2225 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994) (#8101)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 11:41:23 +00:00
Dhruwang Jariwala f6aa27ba8c fix: chart date range type switch + presets include today (ENG-1034, ENG-1035) (#8096)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-21 11:05:10 +00:00
Johannes 82765f7dd7 fix: allow enterprise oauth display names (#8099)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:59:35 +00:00
Dhruwang Jariwala d5bbafcf90 fix: remount AI translation editor on value change, not disabled transition (#8084) 2026-05-21 10:09:57 +00:00
Anshuman Pandey db87a588b5 fix: adds close button on response error screen (#8093) 2026-05-21 09:26:47 +00:00
Javi Aguilar c834587c8d chore: add typecheck command and fix format and type issues (#7999) 2026-05-21 08:13:46 +00:00
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
188 changed files with 2414 additions and 546 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { App } from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -1111,27 +1111,23 @@ export const getResponsesForSummary = reactCache(
skip: offset,
});
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId"
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
};
})
);
const transformedResponses: TSurveySummaryResponse[] = responses.map((responsePrisma) => ({
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId"
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
}));
return transformedResponses;
} catch (error) {
@@ -1,10 +1,11 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
+11 -2
View File
@@ -313,9 +313,18 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
test("returns 404 notFound for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
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",
},
});
});
test("returns 500 internalServerError for unknown errors", async () => {
+4 -5
View File
@@ -29,11 +29,10 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
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,6 +245,34 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
@@ -1,7 +1,12 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganization } from "@/lib/organization/service";
@@ -155,6 +160,16 @@ 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,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
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";
@@ -11,6 +16,7 @@ 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";
@@ -104,6 +110,16 @@ 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,
@@ -131,6 +147,13 @@ 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");
}
@@ -161,15 +161,11 @@ export const getResponsesByWorkspaceIds = reactCache(
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
return transformedResponses;
} catch (error) {
@@ -205,15 +201,11 @@ export const getResponses = reactCache(
skip: offset,
});
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
return transformedResponses;
} catch (error) {
@@ -2,7 +2,12 @@ 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, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
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";
@@ -190,7 +195,19 @@ describe("createResponse V2", () => {
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
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 () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
@@ -199,7 +216,7 @@ describe("createResponse V2", () => {
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
).rejects.toThrow(InvalidInputError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
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";
@@ -12,6 +17,7 @@ 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";
@@ -99,6 +105,16 @@ 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;
@@ -122,6 +138,13 @@ 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,6 +415,44 @@ 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,6 +40,40 @@ 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
@@ -0,0 +1,76 @@
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("");
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
+1 -1
View File
@@ -237,4 +237,4 @@ export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
// Control hash for constant-time password verification to prevent timing attacks. Used when user doesn't exist to maintain consistent verification timing
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q"; //NOSONAR not a real password hash, used only for timing-safe comparison
+53 -1
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 } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
export const selectDisplay = {
@@ -146,6 +146,58 @@ 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,14 +3,18 @@ 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, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, 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 = [
@@ -290,3 +294,96 @@ 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,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -324,5 +325,35 @@ 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);
});
});
});
+17 -11
View File
@@ -3,6 +3,7 @@ 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";
@@ -337,17 +338,15 @@ export const getResponses = reactCache(
skip: offset,
});
const transformedResponses: TResponseWithQuotas[] = await Promise.all(
responses.map((responsePrisma) => {
const { quotaLinks, ...response } = responsePrisma;
return {
...response,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
};
})
);
const transformedResponses: TResponseWithQuotas[] = responses.map((responsePrisma) => {
const { quotaLinks, ...response } = responsePrisma;
return {
...response,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
};
});
return transformedResponses;
} catch (error) {
@@ -569,6 +568,13 @@ 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,6 +38,50 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p }));
const buffer = convertToXlsxBuffer(["name"], rows);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
payloads.forEach((p, i) => {
const cell = sheet[`A${i + 2}`]; // row 1 is header
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${p}`);
});
});
test("should defang formula injection in xlsx header names", () => {
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const headerCell = sheet["A1"];
expect(headerCell.f).toBeUndefined();
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
+1 -1
View File
@@ -9,7 +9,7 @@ describe("promises utilities", () => {
const promise = delay(delayTime);
vi.advanceTimersByTime(delayTime);
await promise;
await expect(promise).resolves.toBeUndefined();
vi.useRealTimers();
});
@@ -165,6 +165,42 @@ 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,6 +85,18 @@ 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 };
@@ -0,0 +1,96 @@
import { describe, expect, test } from "vitest";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "./date-presets";
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
});
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
describe("expandPresetDateRanges", () => {
test("includes today for 'last 7 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
});
test("includes today for 'last 30 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
});
test("expands 'today' to today..today", () => {
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
});
test("expands 'yesterday' to yesterday..yesterday", () => {
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
});
test("'this month' runs from the 1st through today", () => {
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
});
test("'last month' is the full previous calendar month", () => {
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
});
test("'last month' handles year rollover", () => {
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
});
test("'this quarter' starts at the first day of the calendar quarter", () => {
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
});
test("'this year' starts on Jan 1", () => {
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
});
test("leaves explicit [start, end] tuple unchanged", () => {
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
});
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
});
test("returns input unchanged when there are no time dimensions", () => {
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
});
test("preserves other timeDimension fields (granularity, dimension)", () => {
const q: TChartQuery = {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
],
};
const result = expandPresetDateRanges(q, NOW);
expect(result.timeDimensions?.[0]).toMatchObject({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: ["2026-05-15", "2026-05-21"],
});
});
test("does not mutate the input query", () => {
const q = queryWithDateRange("last 7 days");
const before = JSON.stringify(q);
expandPresetDateRanges(q, NOW);
expect(JSON.stringify(q)).toBe(before);
});
});
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -6,6 +8,17 @@ import {
getFilterOperatorsForType,
} from "./schema-definition";
const chartCubeSchemaPath = fileURLToPath(
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
);
const dockerCubeSchemaPath = fileURLToPath(
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
);
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
describe("schema-definition", () => {
describe("getFilterOperatorsForType", () => {
test("returns string operators", () => {
@@ -94,5 +107,20 @@ describe("schema-definition", () => {
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
test("only exposes members present in the deployed Cube schema", () => {
const chartCubeSchema = readChartCubeSchema();
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
getCubeMemberName(id)
);
for (const member of exposedMembers) {
expect(chartCubeSchema).toContain(` ${member}: {`);
}
});
test("keeps the Helm and Docker Cube schemas in sync", () => {
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
});
});
});
@@ -73,10 +73,4 @@ describe("getContacts", () => {
where: { workspaceId: { in: mockWorkspaceIds } },
});
});
test("should get contacts", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
await getContacts(mockWorkspaceIds);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import {
ZContact,
@@ -648,10 +648,10 @@ describe("validateUniqueAttributeKeys", () => {
},
];
const mockCtx = {
addIssue: () => {},
addIssue: vi.fn(),
} as any;
// Should not throw or call addIssue
validateUniqueAttributeKeys(attributes, mockCtx);
expect(mockCtx.addIssue).not.toHaveBeenCalled();
});
test("should fail validation for duplicate attribute keys", () => {
+4 -3
View File
@@ -292,9 +292,10 @@ describe("Quota Utils", () => {
test("should handle empty quota arrays within transaction", async () => {
await upsertResponseQuotaLinks(mockResponseId, [], [], [], asTx(mockTx));
// Verify transaction was called even with empty arrays
// expect(mockTx).toHaveBeenCalledTimes(1);
// expect(mockTx).toHaveBeenCalledWith(expect.any(Function));
// deleteMany always runs; create/update are skipped when arrays are empty
expect(mockTx.responseQuotaLink.deleteMany).toHaveBeenCalledTimes(1);
expect(mockTx.responseQuotaLink.createMany).not.toHaveBeenCalled();
expect(mockTx.responseQuotaLink.updateMany).not.toHaveBeenCalled();
});
test("should execute correct operations within transaction", async () => {
@@ -125,14 +125,16 @@ describe("Team Management", () => {
describe("error handling", () => {
test("handles missing default team gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
await createDefaultTeamMembership(MOCK_IDS.userId);
await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined();
expect(prisma.teamUser.upsert).not.toHaveBeenCalled();
});
test("handles missing organization membership gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
await createDefaultTeamMembership(MOCK_IDS.userId);
await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined();
expect(prisma.teamUser.upsert).not.toHaveBeenCalled();
});
test("handles database errors gracefully", async () => {
@@ -140,7 +142,7 @@ describe("Team Management", () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP);
vi.mocked(prisma.teamUser.upsert).mockRejectedValue(new Error("Database error"));
await createDefaultTeamMembership(MOCK_IDS.userId);
await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined();
});
});
});
+43
View File
@@ -5,6 +5,7 @@ import {
createFeedbackRecord,
createFeedbackRecordsBatch,
deleteFeedbackRecord,
deleteHubTenantData,
getFeedbackRecordTenant,
listFeedbackRecords,
retrieveFeedbackRecord,
@@ -344,6 +345,48 @@ 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);
@@ -103,6 +103,7 @@ describe("getActionClasses", () => {
// We need to import the actual react cache to test it with vi.spyOn if we weren't mocking it.
// However, since we are mocking it to be a pass-through, we just check if our main cache is called.
await getActionClasses(workspaceId);
const result = await getActionClasses(workspaceId);
expect(result).toEqual(mockActionClasses);
});
});
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -123,11 +123,7 @@ export const SurveyLoadingAnimation = ({
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
)}>
{isBrandingEnabled && (
<Image
src={Logo as string}
alt="Logo"
className={cn("w-32 transition-all duration-1000 md:w-40")}
/>
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
)}
<LoadingSpinner />
</div>
@@ -87,6 +87,7 @@ export const SurveyBgSelectorTab = ({
if (isUnsplashConfigured) {
return <ImageFromUnsplashSurveyBg handleBgChange={handleBgChange} />;
}
return null;
default:
return null;
}
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
<DropdownMenuPrimitive.SubContent
ref={ref as any}
className={cn(
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
className
)}
{...props}
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
ref={ref}
sideOffset={sideOffset}
className={cn(
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
align={align}
sideOffset={sideOffset}
className={cn(
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
ref={ref}
sideOffset={sideOffset}
className={cn(
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
className
)}
{...props}
+2
View File
@@ -10,6 +10,8 @@
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
"build:dev": "pnpm run build",
"start": "next start",
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
+1
View File
@@ -60,5 +60,6 @@ test.describe("CX Onboarding", async () => {
await page.getByRole("button", { name: "Save & Close" }).click();
await page.waitForURL(/\/workspaces\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await expect(page).toHaveURL(/\/workspaces\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
});
});
+8
View File
@@ -0,0 +1,8 @@
import "@prisma/client";
declare module "@prisma/client" {
namespace Prisma {
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"exclude": [
"../../.env",
".next",
"node_modules",
"playwright",
"**/*.test.ts",
"**/*.test.tsx",
"**/tests/**",
"**/__mocks__/**",
"**/__tests__/**"
],
"extends": "./tsconfig.json",
"include": [
"next-env.d.ts",
"**/*.d.ts",
"app/**/*.ts",
"app/**/*.tsx",
"lib/**/*.ts",
"lib/**/*.tsx",
"modules/**/*.ts",
"modules/**/*.tsx",
"scripts/**/*.ts",
"../../packages/types/*.d.ts"
]
}
+1 -1
View File
@@ -247,6 +247,6 @@ vi.mock("@/lib/constants", async (importOriginal) => {
RATE_LIMITING_DISABLED: false,
TELEMETRY_DISABLED: false,
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", //NOSONAR mirrors production CONTROL_HASH, not a real password hash
};
});
+3 -2
View File
@@ -65,6 +65,7 @@ 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:
@@ -129,7 +130,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 | `""` | |
| deployment.image.digest | string | `""` | When set, takes precedence over tag. |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.image.tag | string | `""` | |
@@ -224,7 +225,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` | |
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | Consecutive missing Job reads before using DB markers. |
| 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 {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
{{ .Release.Name | camelcase }} with {{ include "formbricks.deploymentImage" . }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
Here's how you can access and manage your deployment:
---
+15 -4
View File
@@ -125,10 +125,6 @@ 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 }}
@@ -157,6 +153,21 @@ 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: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
image: {{ include "formbricks.deploymentImage" . }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
{{- if .Values.deployment.command }}
command:
+194 -11
View File
@@ -39,22 +39,35 @@ spec:
initContainers:
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
- name: wait-for-formbricks-migration
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
image: {{ include "formbricks.deploymentImage" . }}
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 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 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 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();
@@ -102,10 +115,125 @@ 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"
@@ -128,12 +256,30 @@ 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 && attempt >= missingJobMaxAttempts) {
console.log(`${jobName} was not found after ${attempt} attempts; assuming it was already cleaned up.`);
return;
}
if (error && error.statusCode === 404) {
missingJobAttempts += 1;
console.log(`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`);
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}`
);
}
}
await sleep(intervalSeconds * 1000);
@@ -145,8 +291,45 @@ 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: {{ include "formbricks.namespace" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
@@ -46,7 +46,7 @@ spec:
{{- end }}
containers:
- name: migration
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
image: {{ include "formbricks.deploymentImage" . }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
command:
- node
+1
View File
@@ -939,6 +939,7 @@ hub:
waitForFormbricksMigration:
enabled: true
maxAttempts: 180
# Consecutive missing Job reads before falling back to persisted Prisma/DataMigration markers.
missingJobMaxAttempts: 12
intervalSeconds: 5
+729 -294
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
---
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.

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

@@ -0,0 +1,10 @@
---
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).
@@ -0,0 +1,10 @@
---
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).
@@ -0,0 +1,10 @@
---
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.

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