Compare commits

..

11 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
29 changed files with 443 additions and 164 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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -1265,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() } },
@@ -1309,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() } },
@@ -1354,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) } },
{

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 }],
},
},
@@ -165,6 +165,7 @@ const buildNumberAttributeFilterWhereClause = async (
attributeKey: {
key: contactAttributeKey,
environmentId,
dataType: "number",
},
valueNumber: null,
},
@@ -183,6 +184,7 @@ const buildNumberAttributeFilterWhereClause = async (
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
WHERE cak.key = $1
AND cak."environmentId" = $4
AND cak."dataType" = 'number'
AND ca."valueNumber" IS NULL
AND ca.value ~ $3
AND ca.value::double precision ${sqlOp} $2

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

@@ -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

@@ -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

@@ -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.*