Compare commits

..

19 Commits

Author SHA1 Message Date
Tiago Farto
4c89e48f9f chore: hub-deployment.yaml fixes 2026-02-19 16:28:00 +00:00
Tiago Farto
86c993cf59 docs: environment vars 2026-02-19 10:41:25 +00:00
Tiago Farto
0b07b7b173 chore: assorted fixes 2026-02-19 10:33:38 +00:00
Tiago Farto
c71d9a0808 chore: assorted changes 2026-02-19 09:27:05 +00:00
Tiago Farto
cda0e9bcb4 chore: same posstgres string on migrations 2026-02-19 09:22:17 +00:00
Tiago Farto
1bf8bda8c9 docs: updated documents regarding hub integration 2026-02-18 10:57:31 +00:00
Tiago Farto
2082618da1 chore: fix missing vector 2026-02-18 10:00:11 +00:00
Tiago Farto
e1c75cde08 chore: add hub image 2026-02-17 17:07:17 +00:00
Theodór Tómas
33542d0c54 fix: default preview colors (#7277)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-17 11:28:58 +00:00
Matti Nannt
f37d22f13d docs: align rate limiting docs with current code enforcement (#7267)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-17 07:42:53 +00:00
Anshuman Pandey
202ae903ac chore: makes rate limit config const (#7274) 2026-02-17 06:49:56 +00:00
Dhruwang Jariwala
6ab5cc367c fix: reduced default height of input (#7259) 2026-02-17 05:11:29 +00:00
Theodór Tómas
21559045ba fix: input placeholder color (#7265) 2026-02-17 05:11:01 +00:00
Theodór Tómas
d7c57a7a48 fix: disabling cache in dev (#7269) 2026-02-17 04:44:22 +00:00
Chowdhury Tafsir Ahmed Siddiki
11b2ef4788 docs: remove stale 'coming soon' placeholders (#7254) 2026-02-16 13:21:12 +00:00
Theodór Tómas
6fefd51cce fix: suggest colors has better succes copy (#7258) 2026-02-16 13:18:46 +00:00
Theodór Tómas
65af826222 fix: matrix table preview (#7257)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-16 13:18:17 +00:00
Anshuman Pandey
12eb54c653 fix: fixes number being passed into string attribute (#7255) 2026-02-16 11:18:59 +00:00
Dhruwang Jariwala
5aa1427e64 fix: input combobx height (#7256) 2026-02-16 10:03:23 +00:00
36 changed files with 497 additions and 375 deletions

View File

@@ -38,6 +38,14 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?schema=public&sslmode=disable
################
# MAIL SETUP #
################

View File

@@ -21,7 +21,6 @@ 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[] = [];
@@ -257,16 +256,10 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.map((choice) => 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);
};
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.map((choice) => choice.imageUrl);
}
});

View File

@@ -18,7 +18,6 @@ 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";
@@ -96,15 +95,12 @@ 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,7 +10,6 @@ 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";
/**
@@ -178,14 +177,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
styling: environmentData.project.styling,
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
surveys: transformedSurveys,
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -44,10 +44,13 @@ 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,6 +41,7 @@ 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 { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,10 +57,7 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
response: responses.successResponse(result.response),
};
} catch (error) {
return {
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -192,7 +190,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
response: responses.successResponse(updated),
};
} 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 { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,9 +54,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -157,6 +155,7 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -16,7 +16,6 @@ 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,
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
return {

View File

@@ -14,7 +14,6 @@ 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({
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
response: responses.successResponse(surveysWithQuestions),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

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

View File

@@ -22,7 +22,6 @@ 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";
@@ -409,10 +408,9 @@ 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,
resolvedResponses,
responses,
elements,
userAttributes,
hiddenFields,

View File

@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", mockQuestions);
validateResponseData([], mockResponseData, "en", true, mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
});
test("should use default language code", () => {
@@ -125,58 +125,25 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only fields present in responseData", () => {
test("should validate only present fields when finished is false", () => {
const partialResponseData: TResponseData = { element1: "test" };
const elementsToValidate = [mockElements[0]];
const partialElements = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en");
validateResponseData(mockBlocks, partialResponseData, "en", false);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
});
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);
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
validateResponseData(mockBlocks, partialResponseData, "en", true);
// Only element1 should be validated, not element2 (even though it's required)
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
[allElements[0]],
responseDataWithOnlyElement1,
"en"
);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "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.
* Only validates elements that have data in responseData - never validates
* all survey elements regardless of completion status.
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
*
* @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,6 +23,7 @@ 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
@@ -41,8 +42,11 @@ export const validateResponseData = (
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
// Always validate only elements that are present in responseData
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
// 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));
// 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 { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -51,10 +51,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
});
@@ -201,6 +198,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -246,10 +244,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
auditLog.newObject = response.data;
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
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 { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
@@ -44,9 +44,7 @@ export const GET = async (request: NextRequest) =>
environmentResponses.push(...res.data.data);
return responses.successResponse({
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
});
return responses.successResponse({ data: environmentResponses });
},
});
@@ -136,6 +134,7 @@ 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

@@ -5,14 +5,10 @@ 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),
},
},
}));
@@ -30,9 +26,7 @@ describe("segmentFilterToPrismaQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
// 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
// Default mock: number filter raw SQL returns one matching contact
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
});
@@ -151,16 +145,7 @@ describe("segmentFilterToPrismaQuery", () => {
},
},
],
OR: [
{
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gt: 30 },
},
},
},
],
OR: [{ id: { in: ["mock-contact-1"] } }],
});
}
});
@@ -772,12 +757,7 @@ describe("segmentFilterToPrismaQuery", () => {
});
expect(subgroup.AND[0].AND[2]).toStrictEqual({
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gte: 18 },
},
},
id: { in: ["mock-contact-1"] },
});
// Segment inclusion
@@ -1178,23 +1158,10 @@ describe("segmentFilterToPrismaQuery", () => {
},
});
// Second subgroup (numeric operators - uses clean Prisma filter post-backfill)
// Second subgroup (numeric operators - now use raw SQL subquery returning contact IDs)
const secondSubgroup = whereClause.AND?.[0];
expect(secondSubgroup.AND[1].AND).toContainEqual({
attributes: {
some: {
attributeKey: { key: "loginCount" },
valueNumber: { gt: 5 },
},
},
});
expect(secondSubgroup.AND[1].AND).toContainEqual({
attributes: {
some: {
attributeKey: { key: "purchaseAmount" },
valueNumber: { lte: 1000 },
},
},
id: { in: ["mock-contact-1"] },
});
// Third subgroup (negation operators in OR clause)
@@ -1265,7 +1232,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "purchaseDate", dataType: "date" },
attributeKey: { key: "purchaseDate" },
OR: [
{ valueDate: { lt: new Date(targetDate) } },
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
@@ -1309,7 +1276,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "signupDate", dataType: "date" },
attributeKey: { key: "signupDate" },
OR: [
{ valueDate: { gt: new Date(targetDate) } },
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
@@ -1354,7 +1321,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "lastActivityDate", dataType: "date" },
attributeKey: { key: "lastActivityDate" },
OR: [
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
{
@@ -1671,15 +1638,8 @@ describe("segmentFilterToPrismaQuery", () => {
mode: "insensitive",
});
// Number filter uses clean Prisma filter post-backfill
expect(andConditions[1]).toEqual({
attributes: {
some: {
attributeKey: { key: "purchaseCount" },
valueNumber: { gt: 5 },
},
},
});
// Number filter uses raw SQL subquery (transition code) returning contact IDs
expect(andConditions[1]).toEqual({ id: { in: ["mock-contact-1"] } });
// 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, dataType: "date" },
attributeKey: { key: contactAttributeKey },
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
},
},
@@ -116,102 +116,59 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
/**
* Builds a Prisma where clause for number attribute filters.
* 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).
* 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.
*
* TODO: After the backfill script has been run and all valueNumber columns are populated,
* remove the un-migrated fallback path entirely.
* revert this to the clean Prisma-only version that queries valueNumber directly.
*/
const buildNumberAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter,
environmentId: string
filter: TSegmentAttributeFilter
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
const { operator } = qualifier;
const numericValue = typeof value === "number" ? value : Number(value);
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 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 }[]>(
if (!sqlOp) {
return {};
}
const matchingContactIds = 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 cak."environmentId" = $4
AND cak."dataType" = 'number'
AND ca."valueNumber" IS NULL
AND ca.value ~ $3
AND ca.value::double precision ${sqlOp} $2
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)
)
`,
contactAttributeKey,
numericValue,
NUMBER_PATTERN_SQL,
environmentId
NUMBER_PATTERN_SQL
);
if (unmigratedMatchingIds.length === 0) {
return migratedFilter;
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__" };
}
const contactIds = unmigratedMatchingIds.map((r) => r.contactId);
return {
OR: [migratedFilter, { id: { in: contactIds } }],
};
return { id: { in: contactIds } };
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
const buildAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter,
environmentId: string
filter: TSegmentAttributeFilter
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
@@ -258,7 +215,7 @@ const buildAttributeFilterWhereClause = async (
// Handle number operators
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
return await buildNumberAttributeFilterWhereClause(filter, environmentId);
return await buildNumberAttributeFilterWhereClause(filter);
}
// For string operators, ensure value is a primitive (not an object or array)
@@ -296,8 +253,7 @@ const buildAttributeFilterWhereClause = async (
* Builds a Prisma where clause from a person filter
*/
const buildPersonFilterWhereClause = async (
filter: TSegmentPersonFilter,
environmentId: string
filter: TSegmentPersonFilter
): Promise<Prisma.ContactWhereInput> => {
const { personIdentifier } = filter.root;
@@ -309,7 +265,7 @@ const buildPersonFilterWhereClause = async (
contactAttributeKey: personIdentifier,
},
};
return await buildAttributeFilterWhereClause(personFilter, environmentId);
return await buildAttributeFilterWhereClause(personFilter);
}
return {};
@@ -358,7 +314,6 @@ const buildDeviceFilterWhereClause = (
const buildSegmentFilterWhereClause = async (
filter: TSegmentSegmentFilter,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
@@ -382,7 +337,7 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath, environmentId, deviceType);
return processFilters(segment.filters, newPath, deviceType);
};
/**
@@ -391,25 +346,19 @@ 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, environmentId);
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
case "person":
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter, environmentId);
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
case "device":
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
case "segment":
return await buildSegmentFilterWhereClause(
filter as TSegmentSegmentFilter,
segmentPath,
environmentId,
deviceType
);
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath, deviceType);
default:
return {};
}
@@ -421,7 +370,6 @@ const processSingleFilter = async (
const processFilters = async (
filters: TBaseFilters,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
if (filters.length === 0) return {};
@@ -438,10 +386,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, environmentId, deviceType);
whereClause = await processSingleFilter(resource, segmentPath, deviceType);
} else {
// If it's a group of filters, process it recursively
whereClause = await processFilters(resource, segmentPath, environmentId, deviceType);
whereClause = await processFilters(resource, segmentPath, deviceType);
}
if (Object.keys(whereClause).length === 0) continue;
@@ -484,7 +432,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, environmentId, deviceType);
const filtersWhereClause = await processFilters(filters, segmentPath, deviceType);
const whereClause = {
AND: [baseWhereClause, filtersWhereClause],

View File

@@ -136,48 +136,28 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
const surveyConnect = surveyId ? { surveys: { connect: { id: surveyId } } } : {};
let data: Prisma.SegmentCreateArgs["data"] = {
environmentId,
title,
description,
isPrivate,
filters,
};
if (surveyId) {
data = {
...data,
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: {
environmentId,
title,
description,
isPrivate,
filters,
...surveyConnect,
},
data,
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
* - If already absolute, returns as-is (backward compatibility for old data)
* - 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
// Already absolute URL - return as-is (backward compatibility for old data)
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
@@ -176,41 +176,3 @@ 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,7 +40,10 @@ 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);
const response = await fetch(
scriptUrl,
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
);
if (!response.ok) {
throw new Error("Failed to load the surveys package");

View File

@@ -7,6 +7,14 @@ It also truncates the name to a maximum of 63 characters and removes trailing hy
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is never lost (63 char limit).
*/}}
{{- define "formbricks.hubname" -}}
{{- $base := include "formbricks.name" . | trunc 59 | trimSuffix "-" }}
{{- printf "%s-hub" $base | trimSuffix "-" }}
{{- end }}
{{/*
Define the application version to be used in labels.

View File

@@ -0,0 +1,82 @@
{{- if .Values.hub.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
spec:
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: hub
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
ports:
- name: http
containerPort: 8080
protocol: TCP
{{- if .Values.hub.existingSecret }}
envFrom:
- secretRef:
name: {{ .Values.hub.existingSecret }}
{{- end }}
{{- if .Values.hub.env }}
env:
{{- range $key, $value := .Values.hub.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- if .Values.hub.resources }}
resources:
{{- toYaml .Values.hub.resources | nindent 12 }}
{{- end }}
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 5
timeoutSeconds: 5
successThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 5
timeoutSeconds: 5
successThreshold: 1
startupProbe:
httpGet:
path: /health
port: 8080
failureThreshold: 30
periodSeconds: 10
{{- end }}

View File

@@ -0,0 +1,53 @@
{{- if and .Values.hub.enabled .Values.hub.existingSecret }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "formbricks.hubname" . }}-migration
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-migration
app.kubernetes.io/managed-by: {{ .Release.Service }}
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "-5"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
ttlSecondsAfterFinished: {{ .Values.hub.migration.ttlSecondsAfterFinished | default 300 }}
backoffLimit: {{ .Values.hub.migration.backoffLimit | default 3 }}
activeDeadlineSeconds: 300
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-migration
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: hub-migrate
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
command:
- sh
- -c
- |
/usr/local/bin/goose -dir /app/migrations postgres "$DATABASE_URL" up && \
/usr/local/bin/river migrate-up --database-url "$DATABASE_URL"
envFrom:
- secretRef:
name: {{ .Values.hub.existingSecret }}
{{- end }}

View File

@@ -0,0 +1,24 @@
{{- if .Values.hub.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: 8080
targetPort: 8080
protocol: TCP
{{- end }}

View File

@@ -304,6 +304,37 @@ serviceMonitor:
path: /metrics
port: metrics
##########################################################
# Hub API Configuration
# Formbricks Hub image: ghcr.io/formbricks/hub
##########################################################
hub:
enabled: false
image:
repository: "ghcr.io/formbricks/hub"
# Pin to a semver tag for reproducible deployments; update on each Hub release.
tag: "1.0.0"
pullPolicy: IfNotPresent
# Secret containing API_KEY and DATABASE_URL for Hub. Use the same app secret (or same DATABASE_URL) to share the Formbricks database.
existingSecret: ""
# Optional env vars (non-secret). Use existingSecret for API_KEY and DATABASE_URL.
env: {}
# Migration job runs goose + river before Hub API starts (pre-install/pre-upgrade hook). Requires existingSecret with DATABASE_URL.
migration:
ttlSecondsAfterFinished: 300
backoffLimit: 3
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
cpu: "100m"
##########################################################
# PostgreSQL Configuration
##########################################################

View File

@@ -1,14 +1,19 @@
services:
# PostgreSQL must load the vector library so Hub (and Formbricks) can use the pgvector extension.
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector:pg18
volumes:
- postgres:/var/lib/postgresql/data
- ./docker/postgres-init-dev:/docker-entrypoint-initdb.d:ro
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- 5432:5432
command: >
postgres
-c shared_preload_libraries=vector
mailhog:
image: arjenz/mailhog
@@ -36,6 +41,36 @@ services:
volumes:
- minio-data:/data
# Run Hub DB migrations (goose + river) before the API starts. Idempotent; runs on every compose up.
hub-migrate:
image: ghcr.io/formbricks/hub:latest
restart: "no"
entrypoint: ["sh", "-c"]
command: ["if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres \"$$DATABASE_URL\" up && /usr/local/bin/river migrate-up --database-url \"$$DATABASE_URL\"; else echo 'Migration tools (goose/river) not in image, skipping migrations.'; fi"]
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public&sslmode=disable
depends_on:
- postgres
# Formbricks Hub API (ghcr.io/formbricks/hub). Shares the same Postgres database as Formbricks by default.
hub:
image: ghcr.io/formbricks/hub:latest
depends_on:
hub-migrate:
condition: service_completed_successfully
ports:
- "8080:8080"
environment:
API_KEY: ${HUB_API_KEY:-dev-api-key}
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public&sslmode=disable
# Explicit Postgres env so migrations and any libpq fallback use the service host, not localhost
PGHOST: postgres
PGPORT: "5432"
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: postgres
PGSSLMODE: disable
volumes:
postgres:
driver: local

View File

@@ -27,3 +27,13 @@ The script will prompt you for the following information:
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
## Formbricks Hub
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`). Hub shares the same database as Formbricks by default.
- **Migrations**: A `hub-migrate` service runs Hubs database migrations (goose + river) before the Hub API starts. It runs on every `docker compose up` and is idempotent.
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` (required). Override `HUB_DATABASE_URL` only if you want Hub to use a separate database (default is the same Formbricks Postgres URL).
- **Development** (`docker-compose.dev.yml`): Hub uses the same Postgres database; `API_KEY` defaults to `dev-api-key` (override with `HUB_API_KEY`).
Hub listens on port **8080**.

View File

@@ -29,6 +29,12 @@ x-environment: &environment
# To use external Redis/Valkey: remove the redis service below and update this URL
REDIS_URL: redis://redis:6379
# Formbricks Hub (port 8080): API key required. Use e.g. openssl rand -hex 32
HUB_API_KEY:
# Hub database URL (optional). Default: same Postgres as Formbricks. Set only if Hub uses a separate DB.
# HUB_DATABASE_URL:
# Set the minimum log level(debug, info, warn, error, fatal)
# LOG_LEVEL: info
@@ -202,7 +208,7 @@ x-environment: &environment
services:
postgres:
restart: always
image: pgvector/pgvector:pg17
image: pgvector/pgvector:pg18
volumes:
- postgres:/var/lib/postgresql/data
environment:
@@ -245,6 +251,33 @@ services:
- ./saml-connection:/home/nextjs/apps/web/saml-connection
<<: *environment
# Run Hub DB migrations (goose + river) before the API starts. Uses same image; migrations are idempotent.
hub-migrate:
image: ghcr.io/formbricks/hub:latest
restart: "no"
entrypoint: ["sh", "-c"]
command: ["if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres \"$$DATABASE_URL\" up && /usr/local/bin/river migrate-up --database-url \"$$DATABASE_URL\"; else echo 'Migration tools (goose/river) not in image, skipping migrations.'; fi"]
environment:
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?schema=public}
depends_on:
postgres:
condition: service_healthy
# Formbricks Hub API (ghcr.io/formbricks/hub). Set HUB_API_KEY. By default shares the Formbricks database; set HUB_DATABASE_URL to use a separate DB.
hub:
restart: always
image: ghcr.io/formbricks/hub:latest
depends_on:
hub-migrate:
condition: service_completed_successfully
postgres:
condition: service_healthy
ports:
- "8080:8080"
environment:
API_KEY: ${HUB_API_KEY:?HUB_API_KEY is required to run Hub}
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?schema=public}
volumes:
postgres:
driver: local

View File

@@ -1,41 +1,94 @@
---
title: "Rate Limiting"
description: "Rate limiting for Formbricks"
description: "Current request rate limits in Formbricks"
icon: "timer"
---
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.
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
## Default Rate Limits
Rate limits are scoped by identifier, depending on the endpoint:
The following rate limits apply to various endpoints:
- 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)
| **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 |
When a limit is exceeded, the API returns `429 Too Many Requests`.
If a request exceeds the defined rate limit, the server will respond with:
## 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:
```json
{
"code": 429,
"error": "Too many requests, Please try after a while!"
"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"
}
}
```
## Disabling Rate Limiting
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
To disable rate limiting, set the following environment variable:
Set:
```bash
RATE_LIMITING_DISABLED=1
```
After making this change, restart your server to apply the new setting.
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.

View File

@@ -77,4 +77,13 @@ These variables are present inside your machine's docker-compose file. Restart t
| AUDIT_LOG_ENABLED | Set this to 1 to enable audit logging. Requires Redis to be configured with the REDIS_URL env variable. | optional | 0 |
| AUDIT_LOG_GET_USER_IP | Set to 1 to include user IP addresses in audit logs from request headers | optional | 0 |
#### Formbricks Hub
When running the stack with [Formbricks Hub](https://github.com/formbricks/hub) (e.g. via docker-compose), the following variables apply to the Hub service:
| Variable | Description | Required | Default |
| ---------------- | ------------------------------------------------------------------------------------------------- | ----------------------- | ----------------------------------------------------------------------- |
| HUB_API_KEY | API key for the Formbricks Hub API (port 8080). Required when the Hub service is run. | required (when Hub runs) | (e.g. `openssl rand -hex 32`) |
| HUB_DATABASE_URL | PostgreSQL connection URL for Hub. Omit to use the same database as Formbricks. | optional | Same as Formbricks `DATABASE_URL` (shared database) |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.

View File

@@ -16,8 +16,6 @@ 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:
@@ -80,13 +78,6 @@ 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,13 +46,7 @@ _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 (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>
### 3. Pre-segment your audience
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.
@@ -62,13 +56,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:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
![Add page URL action](/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`:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
![Add inner text action](/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,13 +54,7 @@ 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 (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>
### 3. Pre-segment your audience
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,11 +137,6 @@ 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);
}