Compare commits

..

19 Commits

Author SHA1 Message Date
Bhagya Amarasinghe
be38c41c6d feat: add pod disruption budget for helm chart (#7339) [Backport to release/4.7] (#7341) 2026-02-24 18:29:23 +05:30
Bhagya Amarasinghe
f4598c3db5 perf(contacts): batch segment evaluation queries into single transaction (#7333) [Backport to release/4.7] (#7340) 2026-02-24 18:28:20 +05:30
Bhagya Amarasinghe
0831c8c31d feat: add pod disruption budget for helm chart (#7339) 2026-02-24 16:41:36 +05:30
Bhagya Amarasinghe
20ce0f9f34 perf(contacts): batch segment evaluation queries into single transaction (#7333)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 16:21:22 +05:30
Anshuman Pandey
56d6d201f8 fix: [Backport] backports adding boolean support (#7324) 2026-02-20 17:52:47 +01:00
Anshuman Pandey
9a32e60441 fix: fixes storage resolution issues (#7310) (#7319) 2026-02-20 16:43:43 +05:30
Dhruwang Jariwala
2eaa098602 fix: always validate only responseData fields in client/management APIs (#7296) [Backport to release/4.7] (#7300)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 15:10:50 +05:30
Anshuman Pandey
e5393997c2 fix: [Backport] backports segment upsert fix (#7299) 2026-02-19 09:55:25 +01:00
Anshuman Pandey
0c47a6eb61 fix: [Backport] backports data type bug (#7298) 2026-02-19 12:54:36 +04:00
pandeymangg
b96bc8809b backport data type bug in the bc code 2026-02-19 14:01:11 +05:30
Dhruwang Jariwala
3b6b804a68 fix: allow CTA elements to proceed when marked required (#7293) [Backport to release/4.7] (#7297)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 09:27:27 +01:00
Anshuman Pandey
5b776b04b1 fix: [Backport] backports query fix (#7289) 2026-02-18 14:28:00 +01:00
Dhruwang Jariwala
e3f3516fa7 fix: default preview colors (#7277) [Backport to release/4.7] (#7278)
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
2026-02-17 17:22:57 +05:30
Dhruwang Jariwala
91486f3dc9 fix: reduced default height of input (#7259) [Backport to release/4.7] (#7276) 2026-02-17 11:22:21 +05:30
Dhruwang Jariwala
8b9179bfcc fix: input placeholder color (#7265) [Backport to release/4.7] (#7275)
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
2026-02-17 11:21:50 +05:30
Dhruwang Jariwala
1b337aeac3 fix: suggest colors has better success copy (#7258) [Backport to release/4.7] (#7273)
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
2026-02-17 10:42:59 +05:30
Dhruwang Jariwala
cd64848cc5 fix: matrix table preview (#7257) [Backport to release/4.7] (#7272)
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
2026-02-17 10:42:46 +05:30
Dhruwang Jariwala
42411694a7 fix: fixes number being passed into string attribute (#7255) [Backport to release/4.7] (#7271)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2026-02-17 10:42:34 +05:30
Dhruwang Jariwala
721d972901 fix: input combobox height (#7256) [Backport to release/4.7] (#7270) 2026-02-17 10:27:16 +05:30
43 changed files with 637 additions and 506 deletions

View File

@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -256,10 +257,16 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl);
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
}
});

View File

@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -95,12 +96,15 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,

View File

@@ -10,6 +10,7 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: environmentData.project.styling,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: transformedSurveys,
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -98,11 +98,10 @@ describe("updateResponseWithQuotaEvaluation", () => {
});
});
test("should return response with quotaFull as undefined when quota evaluation returns no quotaFull", async () => {
test("should return response without quotaFull when quota evaluation returns no quotaFull", async () => {
mockUpdateResponse.mockResolvedValue(mockResponse);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: false,
quotaFull: undefined,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
@@ -118,11 +117,8 @@ describe("updateResponseWithQuotaEvaluation", () => {
tx: mockTx,
});
expect(result).toEqual({
...mockResponse,
quotaFull: undefined,
});
expect(result).toHaveProperty("quotaFull");
expect(result).toEqual(mockResponse);
expect(result).not.toHaveProperty("quotaFull");
});
test("should use default language when response language is null", async () => {
@@ -130,7 +126,6 @@ describe("updateResponseWithQuotaEvaluation", () => {
mockUpdateResponse.mockResolvedValue(responseWithNullLanguage);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: false,
quotaFull: undefined,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
@@ -145,9 +140,6 @@ describe("updateResponseWithQuotaEvaluation", () => {
tx: mockTx,
});
expect(result).toEqual({
...responseWithNullLanguage,
quotaFull: undefined,
});
expect(result).toEqual(responseWithNullLanguage);
});
});

View File

@@ -23,7 +23,7 @@ export const updateResponseWithQuotaEvaluation = async (
return {
...response,
quotaFull: quotaResult.quotaFull,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});

View File

@@ -44,13 +44,10 @@ const validateResponse = (
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);

View File

@@ -163,7 +163,7 @@ describe("createResponseWithQuotaEvaluation", () => {
mockIsFormbricksCloud = false;
});
test("should return response with quotaFull as undefined when no quota violations", async () => {
test("should return response without quotaFull when no quota violations", async () => {
// Mock quota evaluation to return no violations
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
@@ -198,9 +198,8 @@ describe("createResponseWithQuotaEvaluation", () => {
displayId: null,
contact: null,
tags: [],
quotaFull: undefined,
});
expect(result).toHaveProperty("quotaFull");
expect(result).not.toHaveProperty("quotaFull");
});
test("should return response with quotaFull when quota is exceeded with endSurvey action", async () => {

View File

@@ -69,7 +69,7 @@ export const createResponseWithQuotaEvaluation = async (
return {
...response,
quotaFull: quotaResult.quotaFull,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});

View File

@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse(result.response),
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
};
} catch (error) {
return {
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse(updated),
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(allResponses),
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
};
}
return {
response: responses.successResponse(result.survey),
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
};
} catch (error) {
return {
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(surveyWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
};
}
return {
response: responses.successResponse(updatedSurvey),
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
};
} catch (error) {
return {

View File

@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(surveysWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -225,13 +225,10 @@ describe("createResponseWithQuotaEvaluation V2", () => {
});
});
test("should create response and return it with quotaFull as null when no quota is full", async () => {
test("should create response and return it without quotaFull when no quota is full", async () => {
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(result).toEqual({
...expectedResponse,
quotaFull: null,
});
expect(result).toEqual(expectedResponse);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
responseId: expectedResponse.id,
@@ -265,19 +262,4 @@ describe("createResponseWithQuotaEvaluation V2", () => {
tx: mockTx,
});
});
test("should handle quota evaluation returning undefined quotaFull without throwing", async () => {
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: undefined,
});
const result: TResponseWithQuotaFull = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(result).toEqual({
...expectedResponse,
quotaFull: undefined,
});
expect(result).toHaveProperty("quotaFull");
});
});

View File

@@ -32,7 +32,7 @@ export const createResponseWithQuotaEvaluation = async (
return {
...response,
quotaFull: quotaResult.quotaFull,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});

View File

@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -408,9 +409,10 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
responses,
resolvedResponses,
elements,
userAttributes,
hiddenFields,

View File

@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", true, mockQuestions);
validateResponseData([], mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
});
test("should use default language code", () => {
@@ -125,25 +125,58 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only present fields when finished is false", () => {
test("should validate only fields present in responseData", () => {
const partialResponseData: TResponseData = { element1: "test" };
const partialElements = [mockElements[0]];
const elementsToValidate = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", false);
validateResponseData(mockBlocks, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
});
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
test("should never validate elements not in responseData", () => {
const blocksWithTwoElements: TSurveyBlock[] = [
...mockBlocks,
{
id: "block2",
name: "Block 2",
elements: [
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
],
},
];
const allElements = [
...mockElements,
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
];
const responseDataWithOnlyElement1: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(allElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", true);
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
// Only element1 should be validated, not element2 (even though it's required)
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
[allElements[0]],
responseDataWithOnlyElement1,
"en"
);
});
});

View File

@@ -9,13 +9,13 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
* Validates response data against survey validation rules.
* Only validates elements that have data in responseData - never validates
* all survey elements regardless of completion status.
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @param finished - Whether the response is finished (defaults to true for management APIs)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
@@ -23,7 +23,6 @@ export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -42,11 +41,8 @@ export const validateResponseData = (
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished
? allElements
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Always validate only elements that are present in responseData
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);

View File

@@ -15,7 +15,7 @@ import {
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -51,7 +51,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse(response);
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
},
});
@@ -198,7 +201,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -244,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
auditLog.newObject = response.data;
}
return responses.successResponse(response);
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
},
action: "updated",
targetType: "response",

View File

@@ -12,7 +12,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
@@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) =>
environmentResponses.push(...res.data.data);
return responses.successResponse({ data: environmentResponses });
return responses.successResponse({
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
});
},
});
@@ -134,7 +136,6 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);

View File

@@ -30,4 +30,4 @@ export const rateLimitConfigs = {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
} as const;
};

View File

@@ -7,10 +7,9 @@ import { validateInputs } from "@/lib/utils/validate";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { getPersonSegmentIds, getSegments } from "./segments";
// Mock the cache functions
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(async (fn) => await fn()), // Just execute the function without caching for tests
withCache: vi.fn(async (fn) => await fn()),
},
}));
@@ -30,15 +29,15 @@ vi.mock("@formbricks/database", () => ({
contact: {
findFirst: vi.fn(),
},
$transaction: vi.fn(),
},
}));
// Mock React cache
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: <T extends (...args: any[]) => any>(fn: T): T => fn, // Return the function with the same type signature
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
};
});
@@ -97,22 +96,20 @@ describe("segments lib", () => {
});
describe("getPersonSegmentIds", () => {
const mockWhereClause = { AND: [{ environmentId: mockEnvironmentId }, {}] };
beforeEach(() => {
vi.mocked(prisma.segment.findMany).mockResolvedValue(
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: { AND: [{ environmentId: mockEnvironmentId }, {}] } },
data: { whereClause: mockWhereClause },
});
});
test("should return person segment IDs successfully", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
typeof prisma.contact,
unknown,
"findFirst"
>);
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -128,12 +125,12 @@ describe("segments lib", () => {
});
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
});
test("should return empty array if no segments exist", async () => {
vi.mocked(prisma.segment.findMany).mockResolvedValue([]); // No segments
vi.mocked(prisma.segment.findMany).mockResolvedValue([]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -144,10 +141,11 @@ describe("segments lib", () => {
expect(result).toEqual([]);
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
test("should return empty array if segments exist but none match", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.$transaction).mockResolvedValue([null, null]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -155,16 +153,14 @@ describe("segments lib", () => {
mockContactUserId,
mockDeviceType
);
expect(result).toEqual([]);
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should call validateInputs with correct parameters", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
typeof prisma.contact,
unknown,
"findFirst"
>);
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
expect(validateInputs).toHaveBeenCalledWith(
@@ -175,14 +171,7 @@ describe("segments lib", () => {
});
test("should return only matching segment IDs", async () => {
// First segment matches, second doesn't
vi.mocked(prisma.contact.findFirst)
.mockResolvedValueOnce({ id: mockContactId } as Prisma.Result<
typeof prisma.contact,
unknown,
"findFirst"
>) // First segment matches
.mockResolvedValueOnce(null); // Second segment does not match
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, null]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -193,6 +182,66 @@ describe("segments lib", () => {
expect(result).toEqual([mockSegmentsData[0].id]);
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should include segments with no filters as always-matching", async () => {
const segmentsWithEmptyFilters = [
{ id: "segment-no-filter", filters: [] },
{ id: "segment-with-filter", filters: [{}] as TBaseFilter[] },
];
vi.mocked(prisma.segment.findMany).mockResolvedValue(
segmentsWithEmptyFilters as Prisma.Result<typeof prisma.segment, unknown, "findMany">
);
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
mockContactId,
mockContactUserId,
mockDeviceType
);
expect(result).toContain("segment-no-filter");
expect(result).toContain("segment-with-filter");
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should skip segments where filter query building fails", async () => {
vi.mocked(segmentFilterToPrismaQuery)
.mockResolvedValueOnce({
ok: true,
data: { whereClause: mockWhereClause },
})
.mockResolvedValueOnce({
ok: false,
error: { type: "bad_request", message: "Invalid filters", details: [] },
});
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
mockContactId,
mockContactUserId,
mockDeviceType
);
expect(result).toEqual(["segment1"]);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should return empty array on unexpected error", async () => {
vi.mocked(prisma.segment.findMany).mockRejectedValue(new Error("Unexpected"));
const result = await getPersonSegmentIds(
mockEnvironmentId,
mockContactId,
mockContactUserId,
mockDeviceType
);
expect(result).toEqual([]);
});
});
});

View File

@@ -37,47 +37,6 @@ export const getSegments = reactCache(
)
);
/**
* Checks if a contact matches a segment using Prisma query
* This leverages native DB types (valueDate, valueNumber) for accurate comparisons
* Device filters are evaluated at query build time using the provided deviceType
*/
const isContactInSegment = async (
contactId: string,
segmentId: string,
filters: TBaseFilters,
environmentId: string,
deviceType: "phone" | "desktop"
): Promise<boolean> => {
// If no filters, segment matches all contacts
if (!filters || filters.length === 0) {
return true;
}
const queryResult = await segmentFilterToPrismaQuery(segmentId, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment"
);
return false;
}
const { whereClause } = queryResult.data;
// Check if this specific contact matches the segment filters
const matchingContact = await prisma.contact.findFirst({
where: {
id: contactId,
...whereClause,
},
select: { id: true },
});
return matchingContact !== null;
};
export const getPersonSegmentIds = async (
environmentId: string,
contactId: string,
@@ -89,23 +48,70 @@ export const getPersonSegmentIds = async (
const segments = await getSegments(environmentId);
// fast path; if there are no segments, return an empty array
if (!segments || !Array.isArray(segments)) {
if (!segments || !Array.isArray(segments) || segments.length === 0) {
return [];
}
// Device filters are evaluated at query build time using the provided deviceType
const segmentPromises = segments.map(async (segment) => {
const filters = segment.filters;
const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType);
return isIncluded ? segment.id : null;
});
// Phase 1: Build all Prisma where clauses concurrently.
// This converts segment filters into where clauses without per-contact DB queries.
const segmentWithClauses = await Promise.all(
segments.map(async (segment) => {
const filters = segment.filters as TBaseFilters | null;
const results = await Promise.all(segmentPromises);
if (!filters || filters.length === 0) {
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
}
return results.filter((id): id is string => id !== null);
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment"
);
return { segmentId: segment.id, whereClause: null };
}
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
})
);
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
const alwaysMatchIds: string[] = [];
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
for (const item of segmentWithClauses) {
if (item.whereClause === null) {
continue;
}
if (Object.keys(item.whereClause).length === 0) {
alwaysMatchIds.push(item.segmentId);
} else {
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
}
}
if (toCheck.length === 0) {
return alwaysMatchIds;
}
// Phase 2: Batch all contact-match checks into a single DB transaction.
// Replaces N individual findFirst queries with one batched round-trip.
const batchResults = await prisma.$transaction(
toCheck.map(({ whereClause }) =>
prisma.contact.findFirst({
where: { id: contactId, ...whereClause },
select: { id: true },
})
)
);
// Phase 3: Collect matching segment IDs
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
return [...alwaysMatchIds, ...dbMatchIds];
} catch (error) {
// Log error for debugging but don't throw to prevent "segments is not iterable" error
logger.warn(
{
environmentId,

View File

@@ -54,7 +54,6 @@ export const prepareNewSDKAttributeForStorage = (
};
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
// String type - only use value column
let stringValue: string;
if (value instanceof Date) {

View File

@@ -130,7 +130,12 @@ export const updateAttributes = async (
const messages: TAttributeUpdateMessage[] = [];
const errors: TAttributeUpdateMessage[] = [];
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
// Coerce boolean values to strings (SDK may send booleans for string attributes)
const coercedAttributes: Record<string, string | number> = {};
for (const [key, value] of Object.entries(contactAttributesParam)) {
coercedAttributes[key] = typeof value === "boolean" ? String(value) : value;
}
const emailValue =
contactAttributesParam.email === null || contactAttributesParam.email === undefined
? null
@@ -154,7 +159,7 @@ export const updateAttributes = async (
const userIdExists = !!existingUserIdAttribute;
// Remove email and/or userId from attributes if they already exist on another contact
let contactAttributes = { ...contactAttributesParam };
let contactAttributes = { ...coercedAttributes };
// Determine what the final email and userId values will be after this update
// Only consider a value as "submitted" if it was explicitly included in the attributes

View File

@@ -5,10 +5,14 @@ import { getSegment } from "../segments";
import { segmentFilterToPrismaQuery } from "./prisma-query";
const mockQueryRawUnsafe = vi.fn();
const mockFindFirst = vi.fn();
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRawUnsafe: (...args: unknown[]) => mockQueryRawUnsafe(...args),
contactAttribute: {
findFirst: (...args: unknown[]) => mockFindFirst(...args),
},
},
}));
@@ -26,7 +30,9 @@ describe("segmentFilterToPrismaQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: number filter raw SQL returns one matching contact
// Default: backfill is complete, no un-migrated rows
mockFindFirst.mockResolvedValue(null);
// Fallback path mock: raw SQL returns one matching contact when un-migrated rows exist
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
});
@@ -145,7 +151,16 @@ describe("segmentFilterToPrismaQuery", () => {
},
},
],
OR: [{ id: { in: ["mock-contact-1"] } }],
OR: [
{
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gt: 30 },
},
},
},
],
});
}
});
@@ -757,7 +772,12 @@ describe("segmentFilterToPrismaQuery", () => {
});
expect(subgroup.AND[0].AND[2]).toStrictEqual({
id: { in: ["mock-contact-1"] },
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gte: 18 },
},
},
});
// Segment inclusion
@@ -1158,10 +1178,23 @@ describe("segmentFilterToPrismaQuery", () => {
},
});
// Second subgroup (numeric operators - now use raw SQL subquery returning contact IDs)
// Second subgroup (numeric operators - uses clean Prisma filter post-backfill)
const secondSubgroup = whereClause.AND?.[0];
expect(secondSubgroup.AND[1].AND).toContainEqual({
id: { in: ["mock-contact-1"] },
attributes: {
some: {
attributeKey: { key: "loginCount" },
valueNumber: { gt: 5 },
},
},
});
expect(secondSubgroup.AND[1].AND).toContainEqual({
attributes: {
some: {
attributeKey: { key: "purchaseAmount" },
valueNumber: { lte: 1000 },
},
},
});
// Third subgroup (negation operators in OR clause)
@@ -1232,7 +1265,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "purchaseDate" },
attributeKey: { key: "purchaseDate", dataType: "date" },
OR: [
{ valueDate: { lt: new Date(targetDate) } },
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
@@ -1276,7 +1309,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "signupDate" },
attributeKey: { key: "signupDate", dataType: "date" },
OR: [
{ valueDate: { gt: new Date(targetDate) } },
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
@@ -1321,7 +1354,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "lastActivityDate" },
attributeKey: { key: "lastActivityDate", dataType: "date" },
OR: [
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
{
@@ -1638,8 +1671,15 @@ describe("segmentFilterToPrismaQuery", () => {
mode: "insensitive",
});
// Number filter uses raw SQL subquery (transition code) returning contact IDs
expect(andConditions[1]).toEqual({ id: { in: ["mock-contact-1"] } });
// Number filter uses clean Prisma filter post-backfill
expect(andConditions[1]).toEqual({
attributes: {
some: {
attributeKey: { key: "purchaseCount" },
valueNumber: { gt: 5 },
},
},
});
// Date filter uses OR fallback with 'valueDate' and string 'value'
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");

View File

@@ -107,7 +107,7 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
return {
attributes: {
some: {
attributeKey: { key: contactAttributeKey },
attributeKey: { key: contactAttributeKey, dataType: "date" },
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
},
},
@@ -116,59 +116,102 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
/**
* Builds a Prisma where clause for number attribute filters.
* Uses a raw SQL subquery to handle both migrated rows (valueNumber populated)
* and un-migrated rows (valueNumber NULL, value contains numeric string).
* This is transition code for the deferred value backfill.
* Uses a clean Prisma query when all rows have valueNumber populated (post-backfill).
* Falls back to a raw SQL subquery for un-migrated rows (valueNumber NULL, value contains numeric string).
*
* TODO: After the backfill script has been run and all valueNumber columns are populated,
* revert this to the clean Prisma-only version that queries valueNumber directly.
* remove the un-migrated fallback path entirely.
*/
const buildNumberAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter
filter: TSegmentAttributeFilter,
environmentId: string
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
const { operator } = qualifier;
const numericValue = typeof value === "number" ? value : Number(value);
const sqlOp = SQL_OPERATORS[operator];
if (!sqlOp) {
return {};
let valueNumberCondition: Prisma.FloatNullableFilter;
switch (operator) {
case "greaterThan":
valueNumberCondition = { gt: numericValue };
break;
case "greaterEqual":
valueNumberCondition = { gte: numericValue };
break;
case "lessThan":
valueNumberCondition = { lt: numericValue };
break;
case "lessEqual":
valueNumberCondition = { lte: numericValue };
break;
default:
return {};
}
const matchingContactIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
const migratedFilter: Prisma.ContactWhereInput = {
attributes: {
some: {
attributeKey: { key: contactAttributeKey },
valueNumber: valueNumberCondition,
},
},
};
const hasUnmigratedRows = await prisma.contactAttribute.findFirst({
where: {
attributeKey: {
key: contactAttributeKey,
environmentId,
dataType: "number",
},
valueNumber: null,
},
select: { id: true },
});
if (!hasUnmigratedRows) {
return migratedFilter;
}
const sqlOp = SQL_OPERATORS[operator];
const unmigratedMatchingIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
`
SELECT DISTINCT ca."contactId"
FROM "ContactAttribute" ca
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
WHERE cak.key = $1
AND (
(ca."valueNumber" IS NOT NULL AND ca."valueNumber" ${sqlOp} $2)
OR
(ca."valueNumber" IS NULL AND ca.value ~ $3 AND ca.value::double precision ${sqlOp} $2)
)
AND cak."environmentId" = $4
AND cak."dataType" = 'number'
AND ca."valueNumber" IS NULL
AND ca.value ~ $3
AND ca.value::double precision ${sqlOp} $2
`,
contactAttributeKey,
numericValue,
NUMBER_PATTERN_SQL
NUMBER_PATTERN_SQL,
environmentId
);
const contactIds = matchingContactIds.map((r) => r.contactId);
if (contactIds.length === 0) {
// Return an impossible condition so the filter correctly excludes all contacts
return { id: "__NUMBER_FILTER_NO_MATCH__" };
if (unmigratedMatchingIds.length === 0) {
return migratedFilter;
}
return { id: { in: contactIds } };
const contactIds = unmigratedMatchingIds.map((r) => r.contactId);
return {
OR: [migratedFilter, { id: { in: contactIds } }],
};
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
const buildAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter
filter: TSegmentAttributeFilter,
environmentId: string
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
@@ -215,7 +258,7 @@ const buildAttributeFilterWhereClause = async (
// Handle number operators
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
return await buildNumberAttributeFilterWhereClause(filter);
return await buildNumberAttributeFilterWhereClause(filter, environmentId);
}
// For string operators, ensure value is a primitive (not an object or array)
@@ -253,7 +296,8 @@ const buildAttributeFilterWhereClause = async (
* Builds a Prisma where clause from a person filter
*/
const buildPersonFilterWhereClause = async (
filter: TSegmentPersonFilter
filter: TSegmentPersonFilter,
environmentId: string
): Promise<Prisma.ContactWhereInput> => {
const { personIdentifier } = filter.root;
@@ -265,7 +309,7 @@ const buildPersonFilterWhereClause = async (
contactAttributeKey: personIdentifier,
},
};
return await buildAttributeFilterWhereClause(personFilter);
return await buildAttributeFilterWhereClause(personFilter, environmentId);
}
return {};
@@ -314,6 +358,7 @@ const buildDeviceFilterWhereClause = (
const buildSegmentFilterWhereClause = async (
filter: TSegmentSegmentFilter,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
@@ -337,7 +382,7 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath, deviceType);
return processFilters(segment.filters, newPath, environmentId, deviceType);
};
/**
@@ -346,19 +391,25 @@ const buildSegmentFilterWhereClause = async (
const processSingleFilter = async (
filter: TSegmentFilter,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
switch (root.type) {
case "attribute":
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter, environmentId);
case "person":
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter, environmentId);
case "device":
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
case "segment":
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath, deviceType);
return await buildSegmentFilterWhereClause(
filter as TSegmentSegmentFilter,
segmentPath,
environmentId,
deviceType
);
default:
return {};
}
@@ -370,6 +421,7 @@ const processSingleFilter = async (
const processFilters = async (
filters: TBaseFilters,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
if (filters.length === 0) return {};
@@ -386,10 +438,10 @@ const processFilters = async (
// Process the resource based on its type
if (isResourceFilter(resource)) {
// If it's a single filter, process it directly
whereClause = await processSingleFilter(resource, segmentPath, deviceType);
whereClause = await processSingleFilter(resource, segmentPath, environmentId, deviceType);
} else {
// If it's a group of filters, process it recursively
whereClause = await processFilters(resource, segmentPath, deviceType);
whereClause = await processFilters(resource, segmentPath, environmentId, deviceType);
}
if (Object.keys(whereClause).length === 0) continue;
@@ -432,7 +484,7 @@ export const segmentFilterToPrismaQuery = reactCache(
// Initialize an empty stack for tracking the current evaluation path
const segmentPath = new Set<string>([segmentId]);
const filtersWhereClause = await processFilters(filters, segmentPath, deviceType);
const filtersWhereClause = await processFilters(filters, segmentPath, environmentId, deviceType);
const whereClause = {
AND: [baseWhereClause, filtersWhereClause],

View File

@@ -136,28 +136,48 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
let data: Prisma.SegmentCreateArgs["data"] = {
environmentId,
title,
description,
isPrivate,
filters,
};
if (surveyId) {
data = {
...data,
surveys: {
connect: {
id: surveyId,
},
},
};
}
const surveyConnect = surveyId ? { surveys: { connect: { id: surveyId } } } : {};
try {
// Private segments use upsert because auto-save may have already created a
// default (empty-filter) segment via connectOrCreate before the user publishes.
// Without upsert the second create hits the (environmentId, title) unique constraint.
if (isPrivate) {
const segment = await prisma.segment.upsert({
where: {
environmentId_title: {
environmentId,
title,
},
},
create: {
environmentId,
title,
description,
isPrivate,
filters,
...surveyConnect,
},
update: {
description,
filters,
...surveyConnect,
},
select: selectSegment,
});
return transformPrismaSegment(segment);
}
const segment = await prisma.segment.create({
data,
data: {
environmentId,
title,
description,
isPrivate,
filters,
...surveyConnect,
},
select: selectSegment,
});

View File

@@ -44,12 +44,12 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
const quotas = await getQuotas(surveyId);
if (!quotas || quotas.length === 0) {
return { shouldEndSurvey: false, quotaFull: undefined };
return { shouldEndSurvey: false };
}
const survey = await getSurvey(surveyId);
if (!survey) {
return { shouldEndSurvey: false, quotaFull: undefined };
return { shouldEndSurvey: false };
}
const isDefaultLanguage = survey.languages.find((lang) => lang.default)?.language.code === language;
const result = evaluateQuotas(survey, data, variables, quotas, isDefaultLanguage ? "default" : language);
@@ -74,6 +74,6 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
};
} catch (error) {
logger.error({ error, responseId }, "Error evaluating quotas for response");
return { shouldEndSurvey: false, quotaFull: undefined };
return { shouldEndSurvey: false };
}
};

View File

@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
/**
* Resolves a storage URL to an absolute URL.
* - If already absolute, returns as-is (backward compatibility for old data)
* - If already absolute, returns as-is
* - If relative (/storage/...), prepends the appropriate base URL
* @param url The storage URL (relative or absolute)
* @param accessType The access type to determine which base URL to use (defaults to "public")
@@ -163,7 +163,7 @@ export const resolveStorageUrl = (
): string => {
if (!url) return "";
// Already absolute URL - return as-is (backward compatibility for old data)
// Already absolute URL - return as-is
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
@@ -176,3 +176,41 @@ export const resolveStorageUrl = (
return url;
};
// Matches the actual storage URL format: /storage/{id}/{public|private}/{filename...}
const STORAGE_URL_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+/;
const isStorageUrl = (value: string): boolean => STORAGE_URL_PATTERN.test(value);
export const resolveStorageUrlAuto = (url: string): string => {
if (!isStorageUrl(url)) return url;
const accessType = url.includes("/private/") ? "private" : "public";
return resolveStorageUrl(url, accessType);
};
/**
* Recursively walks an object/array and resolves all relative storage URLs
* Preserves the original structure; skips Date instances and non-object primitives.
*/
export const resolveStorageUrlsInObject = <T>(obj: T): T => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === "string") {
return resolveStorageUrlAuto(obj) as T;
}
if (typeof obj !== "object") return obj;
if (obj instanceof Date) return obj;
if (Array.isArray(obj)) {
return obj.map((item) => resolveStorageUrlsInObject(item)) as T;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = resolveStorageUrlsInObject(value);
}
return result as T;
};

View File

@@ -40,10 +40,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
isLoadingScript = true;
try {
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
const response = await fetch(
scriptUrl,
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
);
const response = await fetch(scriptUrl);
if (!response.ok) {
throw new Error("Failed to load the surveys package");

View File

@@ -118,7 +118,7 @@ Scaling:
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
```
{{- else }}
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.replicaCount }}` replicas.
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.deployment.replicas }}` replicas.
Manually scale using:
```sh
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
@@ -127,6 +127,34 @@ Scaling:
---
Pod Disruption Budget:
{{- if .Values.pdb.enabled }}
A PodDisruptionBudget is active to protect against voluntary disruptions.
{{- if not (kindIs "invalid" .Values.pdb.minAvailable) }}
- **Min Available**: `{{ .Values.pdb.minAvailable }}`
{{- end }}
{{- if not (kindIs "invalid" .Values.pdb.maxUnavailable) }}
- **Max Unavailable**: `{{ .Values.pdb.maxUnavailable }}`
{{- end }}
Check PDB status:
```sh
kubectl get pdb -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
```
{{- if and .Values.autoscaling.enabled (eq (int .Values.autoscaling.minReplicas) 1) }}
WARNING: autoscaling.minReplicas is 1. With minAvailable: 1, the PDB
will block all node drains when only 1 replica is running. Set
autoscaling.minReplicas to at least 2 for proper HA protection.
{{- end }}
{{- else }}
PDB is **not enabled**. Voluntary disruptions (node drains, upgrades) may
take down all pods simultaneously.
{{- end }}
---
External Secrets:
{{- if .Values.externalSecret.enabled }}
External secrets are enabled.

View File

@@ -0,0 +1,37 @@
{{- if .Values.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "pdb.minAvailable and pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "pdb.enabled is true but neither pdb.minAvailable nor pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ template "formbricks.name" . }}
labels:
{{- include "formbricks.labels" . | nindent 4 }}
{{- with .Values.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.pdb.annotations }}
annotations:
{{- toYaml .Values.pdb.annotations | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
{{- end }}
{{- if .Values.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ .Values.pdb.unhealthyPodEvictionPolicy }}
{{- end }}
selector:
matchLabels:
{{- include "formbricks.selectorLabels" . | nindent 6 }}
{{- end }}

View File

@@ -214,6 +214,42 @@ autoscaling:
value: 2
periodSeconds: 60 # Add at most 2 pods every minute
##########################################################
# Pod Disruption Budget (PDB)
#
# Ensures a minimum number of pods remain available during
# voluntary disruptions (node drains, cluster upgrades, etc.).
#
# IMPORTANT:
# - minAvailable and maxUnavailable are MUTUALLY EXCLUSIVE.
# Setting both will cause a helm install/upgrade failure.
# To switch, set the unused one to null in your override file.
# - Accepts an integer (e.g., 1) or a percentage string (e.g., "25%").
# - For PDB to provide real HA protection, ensure
# autoscaling.minReplicas >= 2 (or deployment.replicas >= 2
# if HPA is disabled). With only 1 replica and minAvailable: 1,
# the PDB will block ALL node drains and cluster upgrades.
##########################################################
pdb:
enabled: true
additionalLabels: {}
annotations: {}
# Minimum pods that must remain available during disruptions.
# Set to null and configure maxUnavailable instead if preferred.
minAvailable: 1
# Maximum pods that can be unavailable during disruptions.
# Mutually exclusive with minAvailable — uncomment and set
# minAvailable to null to use this instead.
# maxUnavailable: 1
# Eviction policy for unhealthy pods (Kubernetes 1.27+).
# "IfHealthy" — unhealthy pods count toward the budget (default).
# "AlwaysAllow" — unhealthy pods can always be evicted,
# preventing them from blocking node drain.
# unhealthyPodEvictionPolicy: AlwaysAllow
##########################################################
# Service Configuration
##########################################################

View File

@@ -4,182 +4,12 @@ description: "Formbricks Self-hosted version migration"
icon: "arrow-right"
---
## v4.7
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
### What Happens Automatically
When Formbricks v4.7 starts for the first time, the data migration will:
1. Analyze all existing contact attribute keys and infer their data types (`text`, `number`, or `date`) based on the stored values
2. Update the `ContactAttributeKey` table with the detected `dataType` for each key
3. **If your instance has fewer than 1,000,000 contact attribute rows**: backfill the new `valueNumber` and `valueDate` columns inline. No manual action is needed.
4. **If your instance has 1,000,000 or more contact attribute rows**: the value backfill is skipped to avoid hitting the migration timeout. You will need to run a standalone backfill script after the upgrade.
<Info>
Most self-hosted instances have far fewer than 1,000,000 contact attribute rows (a typical setup with 100K
contacts and 5-10 attributes each lands around 500K-1M rows). If you are below the threshold, the migration
handles everything automatically and you can skip the manual backfill step below.
</Info>
### Steps to Migrate
**1. Backup your Database**
<Tabs>
<Tab title="Docker">
Before running these steps, navigate to the `formbricks` directory where your `docker-compose.yml` file is located.
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
</Tab>
<Tab title="Kubernetes">
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
```bash
kubectl exec -n formbricks formbricks-postgresql-0 -- pg_dump -Fc -U formbricks -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If your PostgreSQL pod has a different name, run `kubectl get pods -n formbricks` to find it.
</Info>
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
</Tab>
</Tabs>
**2. Upgrade to Formbricks v4.7**
<Tabs>
<Tab title="Docker">
```bash
# Pull the latest version
docker compose pull
# Stop the current instance
docker compose down
# Start with Formbricks v4.7
docker compose up -d
```
</Tab>
<Tab title="Kubernetes">
```bash
helm upgrade formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \
-n formbricks \
--set deployment.image.tag=v4.7.0
```
<Info>
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
PreSync hook before the new pods start. No manual migration step is needed.
</Info>
</Tab>
</Tabs>
**3. Check the Migration Logs**
After Formbricks starts, check the logs to see whether the value backfill was completed or skipped:
<Tabs>
<Tab title="Docker">
```bash
docker compose logs formbricks | grep -i "backfill"
```
</Tab>
<Tab title="Kubernetes">
```bash
# Check the application pod logs
kubectl logs -n formbricks -l app.kubernetes.io/name=formbricks --tail=200 | grep -i "backfill"
```
If the Helm migration Job ran, you can also inspect its logs:
```bash
kubectl logs -n formbricks job/formbricks-migration
```
</Tab>
</Tabs>
If you see a message like `Skipping value backfill (X rows >= 1000000 threshold)`, proceed to step 4. Otherwise, the migration is complete and no further action is needed.
**4. Run the Backfill Script (large datasets only)**
If the migration skipped the value backfill, run the standalone backfill script inside the running Formbricks container:
<Tabs>
<Tab title="Docker">
```bash
docker exec formbricks node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
</Tab>
<Tab title="Kubernetes">
```bash
kubectl exec -n formbricks deploy/formbricks -- node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
</Info>
</Tab>
</Tabs>
The script will output progress as it runs:
```
========================================
Attribute Value Backfill Script
========================================
Fetching number-type attribute keys...
Found 12 number-type keys. Backfilling valueNumber...
Number backfill progress: 10/12 keys (48230 rows updated)
Number backfill progress: 12/12 keys (52104 rows updated)
Fetching date-type attribute keys...
Found 5 date-type keys. Backfilling valueDate...
Date backfill progress: 5/5 keys (31200 rows updated)
========================================
Backfill Complete!
========================================
valueNumber rows updated: 52104
valueDate rows updated: 31200
Duration: 42.3s
========================================
```
Key characteristics of the backfill script:
- **Safe to run while Formbricks is live** -- it does not lock the entire table or wrap work in a long transaction
- **Idempotent** -- it only updates rows where the typed columns are still `NULL`, so you can safely run it multiple times
- **Resumable** -- each batch commits independently, so if the process is interrupted you can re-run it and it picks up where it left off
- **No timeout risk** -- unlike the migration, this script runs outside the migration transaction and has no time limit
**5. Verify the Upgrade**
- Access your Formbricks instance at the same URL as before
- If you use contact segments with number or date filters, verify they return the expected results
- Check that existing surveys and response data are intact
---
## v4.0
<Warning>
**Important: Migration Required**
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
</Warning>
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
@@ -187,11 +17,9 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
### What's New in Formbricks 4.0
**🚀 New Enterprise Features:**
- **Quotas Management**: Advanced quota controls for enterprise users
**🏗️ Technical Foundation Improvements:**
- **Enhanced File Storage**: Improved file handling with better performance and reliability
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
- **Database Optimization**: Removal of unused database tables and fields for better performance
@@ -211,8 +39,7 @@ These services are already included in the updated one-click setup for self-host
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Advanced features** that require sophisticated caching and file processing
- **Better performance** through optimized, dedicated services
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
@@ -225,7 +52,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
### One-Click Setup
For users using our official one-click setup, we provide an automated migration using a migration script:
For users using our official one-click setup, we provide an automated migration using a migration script:
```bash
# Download the latest script
@@ -240,11 +67,11 @@ chmod +x migrate-to-v4.sh
```
This script guides you through the steps for the infrastructure migration and does the following:
- Adds a Redis service to your setup and configures it
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
- Pulls the latest Formbricks image and updates your instance
### Manual Setup
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
@@ -260,7 +87,6 @@ You need to configure the `REDIS_URL` environment variable and point it to your
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
Formbricks supports multiple storage providers (among many other S3-compatible storages):
- AWS S3
- Digital Ocean Spaces
- Hetzner Object Storage
@@ -275,7 +101,6 @@ Please make sure to set up a storage bucket with one of these solutions and then
S3_BUCKET_NAME: formbricks-uploads
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
```
#### Upgrade Process
**1. Backup your Database**
@@ -287,8 +112,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
If you run into "**No such container**", use `docker ps` to find your container name,
e.g. `formbricks_postgres_1`.
</Info>
**2. Upgrade to Formbricks 4.0**
@@ -309,7 +134,6 @@ docker compose up -d
**3. Automatic Database Migration**
When you start Formbricks 4.0 for the first time, it will **automatically**:
- Detect and apply required database schema updates
- Remove unused database tables and fields
- Optimize the database structure for better performance

View File

@@ -1,94 +1,41 @@
---
title: "Rate Limiting"
description: "Current request rate limits in Formbricks"
description: "Rate limiting for Formbricks"
icon: "timer"
---
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
Rate limits are scoped by identifier, depending on the endpoint:
## Default Rate Limits
- IP hash (for unauthenticated/client-side routes and public actions)
- API key ID (for authenticated API calls)
- User ID (for authenticated session-based calls and server actions)
- Organization ID (for follow-up email dispatch)
The following rate limits apply to various endpoints:
When a limit is exceeded, the API returns `429 Too Many Requests`.
| **Endpoint** | **Rate Limit** | **Time Window** |
| ----------------------- | -------------- | --------------- |
| `POST /login` | 30 requests | 15 minutes |
| `POST /signup` | 30 requests | 60 minutes |
| `POST /verify-email` | 10 requests | 60 minutes |
| `POST /forgot-password` | 5 requests | 60 minutes |
| `GET /client-side-api` | 100 requests | 1 minute |
| `POST /share` | 100 requests | 60 minutes |
## Management API Rate Limits
These are the current limits for Management APIs:
| **Route Group** | **Limit** | **Window** | **Identifier** |
| --- | --- | --- | --- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
## All Enforced Limits
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| --- | --- | --- | --- | --- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
## Current Endpoint Exceptions
The following routes are currently not rate-limited by the server-side limiter:
- `GET /api/v1/client/og` (explicitly excluded)
- `POST /api/v2/client/[environmentId]/responses`
- `POST /api/v2/client/[environmentId]/displays`
- `GET /api/v2/health`
## 429 Response Shape
v1-style endpoints return:
If a request exceeds the defined rate limit, the server will respond with:
```json
{
"code": "too_many_requests",
"message": "Maximum number of requests reached. Please try again later.",
"details": {}
}
```
v2-style endpoints return:
```json
{
"error": {
"code": 429,
"message": "Too Many Requests"
}
"code": 429,
"error": "Too many requests, Please try after a while!"
}
```
## Disabling Rate Limiting
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
Set:
To disable rate limiting, set the following environment variable:
```bash
RATE_LIMITING_DISABLED=1
```
After changing this value, restart the server.
## Operational Notes
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
After making this change, restart your server to apply the new setting.

View File

@@ -16,6 +16,8 @@ The Churn Survey is among the most effective ways to identify weaknesses in your
* Follow-up to prevent bad reviews
* Coming soon: Make survey mandatory
## Overview
To run the Churn Survey in your app you want to proceed as follows:
@@ -78,6 +80,13 @@ Whenever a user visits this page, matches the filter conditions above and the re
Here is our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions/) covering [No-Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) and [Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) Actions.
<Note>
Pre-churn flow coming soon Were currently building full-screen survey
pop-ups. Youll be able to prevent users from closing the survey unless they
respond to it. Its certainly debatable if you want that but you could force
them to click through the survey before letting them cancel 🤷
</Note>
### 5. Select Action in the “When to ask” card
![Select feedback button action](/images/xm-and-surveys/xm/best-practices/cancel-subscription/select-action.webp)

View File

@@ -46,7 +46,13 @@ _Want to change the button color? Adjust it in the project settings!_
Save, and move over to the **Audience** tab.
### 3. Pre-segment your audience
### 3. Pre-segment your audience (coming soon)
<Note>
### Filter by Attribute Coming Soon
We're working on pre-segmenting users by attributes. This manual will be updated in the coming days.
</Note>
Pre-segmentation isn't needed for this survey since you likely want to target all users who cancel their trial. You can use a specific user action, like clicking **Cancel Trial**, to show the survey only to users trying your product.
@@ -56,13 +62,13 @@ How you trigger your survey depends on your product. There are two options:
- **Trigger by Page view:** If you have a page like `/trial-cancelled` for users who cancel their trial subscription, create a user action with the type "Page View." Select "Limit to specific pages" and apply URL filters with these settings:
![Add page URL action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
Whenever a user visits this page, the survey will be displayed ✅
- **Trigger by Button Click:** In a different case, you have a “Cancel Trial" button in your app. You can setup a user Action with the `Inner Text`:
![Add inner text action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.

View File

@@ -54,7 +54,13 @@ In the button settings you have to make sure it is set to “External URL”. In
Save, and move over to the “Audience” tab.
### 3. Pre-segment your audience
### 3. Pre-segment your audience (coming soon)
<Note>
## Filter by attribute coming soon. We're working on pre-segmenting users by
attributes. We will update this manual in the next few days.
</Note>
Once you clicked over to the “Audience” tab you can change the settings. In the **Who To Send** card, select “Filter audience by attribute”. This allows you to only show the prompt to a specific segment of your user base.

View File

@@ -137,6 +137,11 @@ const checkRequiredField = (
return null;
}
// CTA elements never block progression (informational only)
if (element.type === TSurveyElementTypeEnum.CTA) {
return null;
}
if (element.type === TSurveyElementTypeEnum.Ranking) {
return validateRequiredRanking(value, t);
}

View File

@@ -16,5 +16,5 @@ export type TContactAttribute = z.infer<typeof ZContactAttribute>;
export const ZContactAttributes = z.record(z.string());
export type TContactAttributes = z.infer<typeof ZContactAttributes>;
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number()]));
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number(), z.boolean()]));
export type TContactAttributesInput = z.infer<typeof ZContactAttributesInput>;

View File

@@ -20,6 +20,9 @@ sonar.scm.exclusions.disabled=false
# Encoding of the source code
sonar.sourceEncoding=UTF-8
# Node.js memory limit for JS/TS analysis (in MB)
sonar.javascript.node.maxspace=8192
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*