Compare commits

..

18 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 9d0169ce81 fix: use Valkey for bundled Helm Redis 2026-05-21 13:03:14 +05:30
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
74 changed files with 1475 additions and 474 deletions
+87 -5
View File
@@ -31,14 +31,14 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Get the latest release tag from GitHub API with error handling
echo "Fetching latest release from GitHub API..."
# Use curl with error handling - API returns 404 if no releases exist
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
if [[ "$http_code" == "404" ]]; then
echo "⚠️ No previous releases found (404). This appears to be the first release."
echo "latest_release=" >> $GITHUB_OUTPUT
@@ -55,7 +55,7 @@ jobs:
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
fi
echo "Current release tag: ${{ github.event.release.tag_name }}"
- name: Compare release tags
@@ -65,7 +65,7 @@ jobs:
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
run: |
set -euo pipefail
# Handle first release case (no previous releases)
if [[ -z "${LATEST_TAG}" ]]; then
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
@@ -156,6 +156,87 @@ jobs:
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
update-helm-app-version:
name: Create Helm app version update
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- helm-chart-release
if: ${{ !github.event.release.prerelease }}
permissions:
contents: write
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Prepare Helm app version update
id: update
env:
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
echo "Helm chart appVersion already matches ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Helm app version PR
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
branch="chore/update-helm-app-version-${VERSION}"
title="chore: update Helm app version to ${VERSION}"
body_file="$(mktemp)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
git commit -m "$title"
git push --force-with-lease origin "$branch"
cat > "$body_file" <<EOF
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
Release candidates and pre-releases do not create this source update.
EOF
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
else
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
fi
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
@@ -165,6 +246,7 @@ jobs:
- docker-build-cloud
- helm-chart-release
- move-stable-tag
- update-helm-app-version
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
+19
View File
@@ -70,6 +70,25 @@ jobs:
echo "✅ Successfully updated Chart.yaml"
- name: Validate default Formbricks image tag
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
rendered="$(helm template qa charts/formbricks \
--set formbricks.webappUrl=https://qa.example.com \
--show-only templates/deployment.yaml \
--show-only templates/migration-job.yaml)"
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
if [[ "$image_count" -ne 2 ]]; then
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
exit 1
fi
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
@@ -194,7 +194,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
href: `/workspaces/${workspace.id}/settings/workspace/general`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -467,7 +467,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton />
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
{/* Settings sidebar content */}
@@ -335,6 +335,7 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -373,12 +374,14 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -1,4 +1,11 @@
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
};
@@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
@@ -1,3 +1,18 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export default PricingPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -12,8 +13,9 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -1,9 +1,10 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -11,15 +12,19 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -1 +1,11 @@
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
@@ -1,3 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -26,8 +27,9 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
export default TeamsPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -11,6 +11,7 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -25,6 +26,7 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
@@ -103,6 +105,8 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
@@ -34,7 +34,7 @@ export const GET = async (req: Request) => {
return responses.unauthorizedResponse();
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
@@ -102,7 +102,7 @@ export const GET = async (req: Request) => {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(`${WEBAPP_URL}/${basePath}/integrations/google-sheets`);
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
+2 -11
View File
@@ -313,18 +313,9 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 404 notFound for ResourceNotFoundError", async () => {
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
+5 -4
View File
@@ -29,10 +29,11 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -103,6 +103,7 @@ describe("getWorkspaceStateData", () => {
id: workspaceId,
appSetupCompleted: true,
workspaceSettings: {
id: workspaceId,
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -111,7 +112,14 @@ describe("getWorkspaceStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
surveys: mockWorkspaceData.surveys,
// `survey.name` is replaced with a back-compat placeholder; segment was
// null in the mock so the sanitized segment stays null.
surveys: [
{
...mockWorkspaceData.surveys[0],
name: "[deprecated] survey name omitted from public API - will be removed soon",
},
],
actionClasses: mockWorkspaceData.actionClasses,
});
@@ -211,6 +219,7 @@ describe("getWorkspaceStateData", () => {
const result = await getWorkspaceStateData(workspaceId);
expect(result.workspace.workspaceSettings).toEqual({
id: workspaceId,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
@@ -42,6 +42,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
where: { id: workspaceId },
select: {
id: true,
legacyEnvironmentId: true,
appSetupCompleted: true,
recontactDays: true,
clickOutsideClose: true,
@@ -72,7 +73,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
select: {
id: true,
welcomeCard: true,
// name intentionally omitted — internal label not needed by the SDK
// `name` deliberately not selected — internal label not needed by the
// SDK and replaced with a fixed placeholder below so older SDKs that
// decoded `Survey.name` as a required field keep working.
questions: true,
blocks: true,
variables: true,
@@ -99,9 +102,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
// Only need to know if any filters exist so we can compute
// `hasFilters`. Real filter values, segment title/description, and
// surveys-list relation are never exposed to clients.
segment: {
select: {
id: true,
@@ -135,17 +138,46 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
throw new ResourceNotFoundError("workspace", workspaceId);
}
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
// Backwards-compat response shape for SDKs from before PR #7931. Those
// clients decoded `survey.name` and the full `segment` object as required
// fields, so the response must still carry that shape — but every field
// that could leak sensitive targeting data is replaced with a placeholder.
// The actual segment-membership check happens server-side (segment IDs in
// POST /user); SDKs only inspect `filters.length` / `hasFilters` locally.
//
// `environmentId` mirrors `legacyEnvironmentId ?? workspace.id`, matching
// the `/me` endpoints' pattern so migrated workspaces keep returning the
// original env ID older clients persisted.
const legacyOrCurrentId = workspaceData.legacyEnvironmentId ?? workspaceData.id;
const placeholderDate = new Date(0);
const placeholderFilter = {
id: "placeholder",
connector: null,
resource: {
id: "placeholder",
root: { type: "device", deviceType: "phone" },
value: "deprecated",
qualifier: { operator: "equals" },
},
};
const transformedSurveys = workspaceData.surveys.map((survey) => {
const minimalSegment = survey.segment
const realHasFilters =
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
const sanitizedSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
title: "[deprecated] segment title omitted from public API - will be removed soon",
description: null,
isPrivate: true,
filters: realHasFilters ? [placeholderFilter] : [],
environmentId: legacyOrCurrentId,
workspaceId: legacyOrCurrentId,
createdAt: placeholderDate,
updatedAt: placeholderDate,
surveys: [],
hasFilters: realHasFilters,
}
: null;
@@ -155,7 +187,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
segment: null,
});
return { ...transformed, segment: minimalSegment };
return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
segment: sanitizedSegment,
};
});
return {
@@ -163,6 +199,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
id: workspaceData.id,
appSetupCompleted: workspaceData.appSetupCompleted,
workspaceSettings: {
id: workspaceData.id,
recontactDays: workspaceData.recontactDays,
clickOutsideClose: workspaceData.clickOutsideClose,
overlay: workspaceData.overlay,
@@ -171,7 +208,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: resolveStorageUrlsInObject(workspaceData.styling),
},
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
// The runtime shape carries extra back-compat fields (placeholder
// segment, `hasFilters`, mirrored `environmentId`) that aren't part of
// the modern `TJsWorkspaceStateSurvey`. Cast through unknown — this is
// intentional and only this endpoint's response widens the type.
surveys: resolveStorageUrlsInObject(transformedSurveys) as unknown as TJsWorkspaceStateSurvey[],
actionClasses: workspaceData.actionClasses,
};
} catch (error) {
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
@@ -34,6 +35,10 @@ vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
validateResponseData: mocks.validateResponseData,
@@ -123,6 +128,7 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
@@ -239,6 +245,34 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
@@ -8,6 +8,7 @@ import { THandlerParams } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -209,7 +210,7 @@ export const putResponseHandler = async ({
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { workspaceId, responseId } = params;
const { workspaceId: workspaceIdParam, responseId } = params;
if (!responseId) {
return {
@@ -217,6 +218,14 @@ export const putResponseHandler = async ({
};
}
const resolved = await resolveClientApiIds(workspaceIdParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", workspaceIdParam, true),
};
}
const { workspaceId } = resolved;
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
if ("response" in validatedUpdateInput) {
return validatedUpdateInput;
@@ -104,7 +104,11 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaClient = tx ?? prisma;
@@ -51,7 +51,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
@@ -40,7 +40,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -37,7 +37,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
@@ -21,24 +21,25 @@ vi.mock("react", async () => {
});
const contactId = "test-contact-id";
const workspaceId = "test-workspace-id";
describe("doesContactExist", () => {
describe("doesContactExistInWorkspace", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return true if contact exists", async () => {
test("should return true if contact exists in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -46,11 +47,11 @@ describe("doesContactExist", () => {
test("should return false if contact does not exist in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -1,15 +1,18 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
},
select: {
id: true,
},
});
export const doesContactExistInWorkspace = reactCache(
async (id: string, workspaceId: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
workspaceId,
},
select: {
id: true,
},
});
return !!contact;
});
return !!contact;
}
);
@@ -9,7 +9,7 @@ import {
} from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
@@ -30,7 +30,7 @@ vi.mock("@formbricks/database", () => ({
}));
vi.mock("./contact", () => ({
doesContactExist: vi.fn(),
doesContactExistInWorkspace: vi.fn(),
}));
const workspaceId = "workspace-id-mock";
@@ -81,13 +81,13 @@ describe("createDisplay", () => {
});
test("should create a display with contactId successfully", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -104,7 +104,7 @@ describe("createDisplay", () => {
const result = await createDisplay(displayInputWithoutContact);
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -115,13 +115,13 @@ describe("createDisplay", () => {
});
test("should create a display without contact if contact does not exist in the workspace", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -139,16 +139,16 @@ describe("createDisplay", () => {
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, workspaceId },
});
@@ -158,7 +158,7 @@ describe("createDisplay", () => {
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
@@ -171,7 +171,7 @@ describe("createDisplay", () => {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
@@ -179,15 +179,15 @@ describe("createDisplay", () => {
test("should throw original error on other errors during creation", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
});
test("should throw original error if doesContactExist fails", async () => {
test("should throw original error if doesContactExistInWorkspace fails", async () => {
const contactCheckError = new Error("Failed to check contact");
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
vi.mocked(doesContactExistInWorkspace).mockRejectedValue(contactCheckError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
expect(prisma.display.create).not.toHaveBeenCalled();
@@ -6,7 +6,7 @@ import {
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[workspaceId]/displays/types/display";
import { validateInputs } from "@/lib/utils/validate";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
const { contactId, surveyId, workspaceId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
const contactExists = contactId ? await doesContactExistInWorkspace(contactId, workspaceId) : false;
const survey = await prisma.survey.findUnique({
where: {
@@ -49,18 +49,7 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
return {
survey: {
@@ -84,8 +73,6 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
+44
View File
@@ -4,6 +4,7 @@ import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
} from "./service";
@@ -207,4 +208,47 @@ describe("AI organization service", () => {
);
});
});
describe("getAISmartToolsUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
test("returns undefined when all checks pass", () => {
expect(getAISmartToolsUnavailableReason(baseConfig)).toBeUndefined();
});
test("returns not_in_plan when smart tools entitlement is missing", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEntitled: false })).toBe(
"not_in_plan"
);
});
test("returns not_enabled when smart tools is disabled at org level", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEnabled: false })).toBe(
"not_enabled"
);
});
test("returns instance_not_configured when instance AI is missing", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
"instance_not_configured"
);
});
test("ignores data-analysis flags (smart tools is independent of data analysis state)", () => {
expect(
getAISmartToolsUnavailableReason({
...baseConfig,
isAIDataAnalysisEntitled: false,
isAIDataAnalysisEnabled: false,
})
).toBeUndefined();
});
});
});
+9
View File
@@ -59,6 +59,15 @@ export const getAIDataAnalysisUnavailableReason = (
return undefined;
};
export const getAISmartToolsUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
if (!aiConfig.isAISmartToolsEntitled) return "not_in_plan";
if (!aiConfig.isAISmartToolsEnabled) return "not_enabled";
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
return undefined;
};
export const assertOrganizationAIConfigured = async (
organizationId: string,
capability: "smartTools" | "dataAnalysis"
-31
View File
@@ -1,7 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -325,35 +324,5 @@ describe("updateResponse", () => {
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
});
});
-8
View File
@@ -3,7 +3,6 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -570,13 +569,6 @@ export const updateResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Response", responseId);
}
throw new DatabaseError(error.message);
}
@@ -38,6 +38,50 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p }));
const buffer = convertToXlsxBuffer(["name"], rows);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
payloads.forEach((p, i) => {
const cell = sheet[`A${i + 2}`]; // row 1 is header
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${p}`);
});
});
test("should defang formula injection in xlsx header names", () => {
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const headerCell = sheet["A1"];
expect(headerCell.f).toBeUndefined();
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
+26 -2
View File
@@ -2,11 +2,30 @@ import { AsyncParser } from "@json2csv/node";
import * as xlsx from "xlsx";
import { logger } from "@formbricks/logger";
// Defang spreadsheet formula injection. Cell values starting with
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
// Sanitize at the render boundary only — never rewrite row keys, since
// distinct user-controlled labels could collide after prefixing (e.g.
// "=field" and "'=field" both map to "'=field"), dropping cell data.
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
const sanitizeFormulaInjection = <T>(value: T): T => {
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
return `'${value}` as T;
}
return value;
};
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
let csv: string = "";
// Field descriptors preserve the original lookup key while overriding the
// rendered label and cell value with sanitized versions.
const parser = new AsyncParser({
fields,
fields: fields.map((name) => ({
label: sanitizeFormulaInjection(name),
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
})),
});
try {
@@ -23,8 +42,13 @@ export const convertToXlsxBuffer = (
fields: string[],
jsonData: Record<string, string | number>[]
): Buffer => {
// Build as array-of-arrays so original row keys are looked up before
// sanitization is applied to the rendered header/cell only.
const headerRow = fields.map(sanitizeFormulaInjection);
const dataRows = jsonData.map((row) => fields.map((name) => sanitizeFormulaInjection(row[name])));
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};
+1 -1
View File
@@ -2429,7 +2429,7 @@
"most_popular": "Самый популярный",
"pending_change_removed": "Запланированное изменение тарифа отменено.",
"pending_plan_badge": "Запланирован",
"pending_plan_change_description": "Твой тариф сменится на {plan} {date}.",
"pending_plan_change_description": "Твой тариф сменится на {plan} на {date}.",
"pending_plan_change_title": "Запланированное изменение тарифа",
"pending_plan_cta": "Запланирован",
"per_month": "в месяц",
@@ -363,8 +363,10 @@ export const generateAIChartAction = authenticatedActionClient
await checkDashboardsEnabled(organizationId);
// Verify AI is entitled, enabled at org level, and configured at instance level
await assertOrganizationAIConfigured(organizationId, "dataAnalysis");
// Verify AI is entitled, enabled at org level, and configured at instance level.
// Uses "smartTools" (not "dataAnalysis") because chart generation only sends the
// Cube schema context and the user's prompt to the LLM — no response PII.
await assertOrganizationAIConfigured(organizationId, "smartTools");
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
@@ -1,5 +1,5 @@
import { use } from "react";
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -87,7 +87,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
getConnectorsWithMappings(workspaceId),
getOrganizationAIConfig(organization.id),
]);
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
const isAIAvailable = !aiUnavailableReason;
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import type { TChartQuery } from "@formbricks/types/analysis";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { executeTenantScopedQuery } from "@/modules/ee/analysis/api/lib/cube-client";
@@ -99,7 +99,7 @@ export async function DashboardDetailPage({
getFeedbackDirectoriesByWorkspaceId(workspaceId),
getOrganizationAIConfig(organization.id),
]);
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
const isAIAvailable = !aiUnavailableReason;
let dashboard;
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -6,6 +8,17 @@ import {
getFilterOperatorsForType,
} from "./schema-definition";
const chartCubeSchemaPath = fileURLToPath(
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
);
const dockerCubeSchemaPath = fileURLToPath(
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
);
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
describe("schema-definition", () => {
describe("getFilterOperatorsForType", () => {
test("returns string operators", () => {
@@ -94,5 +107,20 @@ describe("schema-definition", () => {
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
test("only exposes members present in the deployed Cube schema", () => {
const chartCubeSchema = readChartCubeSchema();
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
getCubeMemberName(id)
);
for (const member of exposedMembers) {
expect(chartCubeSchema).toContain(` ${member}: {`);
}
});
test("keeps the Helm and Docker Cube schemas in sync", () => {
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
});
});
});
@@ -436,17 +436,15 @@ export const PricingTable = ({
<Alert variant="info" className="max-w-4xl">
<AlertTitle>{t("workspace.settings.billing.pending_plan_change_title")}</AlertTitle>
<AlertDescription>
{t("workspace.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace(
"{{date}}",
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
)}
{t("workspace.settings.billing.pending_plan_change_description", {
plan: getCurrentCloudPlanLabel(pendingChange.targetPlan, t),
date: formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
}),
})}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
@@ -13,7 +13,7 @@ export const ManageTeam = () => {
const router = useRouter();
const handleManageTeams = () => {
router.push(`${workspaceBasePath}/settings/teams`);
router.push(`${workspaceBasePath}/settings/organization/teams`);
};
return (
@@ -1,6 +1,7 @@
"use client";
import { EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { CopyIcon, EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -9,9 +10,12 @@ import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { copySurveyToOtherWorkspaceAction } from "@/modules/survey/list/actions";
import { surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
@@ -42,9 +46,11 @@ export const SurveyDropDownMenu = ({
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const router = useRouter();
const queryClient = useQueryClient();
const editHref = `/workspaces/${workspace?.id}/surveys/${survey.id}/edit`;
@@ -85,6 +91,29 @@ export const SurveyDropDownMenu = ({
setIsCautionDialogOpen(true);
};
const handleDuplicateSurvey = async () => {
if (!workspace?.id) return;
setIsDuplicating(true);
setIsDropDownOpen(false);
try {
const response = await copySurveyToOtherWorkspaceAction({
surveyId: survey.id,
targetWorkspaceId: workspace.id,
});
if (response?.data) {
toast.success(t("workspace.surveys.survey_duplicated_successfully"));
await queryClient.invalidateQueries({ queryKey: surveyKeys.lists() });
return;
}
toast.error(getFormattedErrorMessage(response));
} catch (error) {
logger.error(error);
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDuplicating(false);
}
};
if (!hasVisibleActions) {
return null;
}
@@ -120,6 +149,22 @@ export const SurveyDropDownMenu = ({
</Link>
</DropdownMenuItem>
)}
{canManageSurvey && (
<DropdownMenuItem>
<button
type="button"
data-testid="duplicate-survey"
className={cn("flex w-full items-center", isDuplicating && "cursor-not-allowed opacity-50")}
disabled={isDuplicating}
onClick={(e) => {
e.preventDefault();
void handleDuplicateSurvey();
}}>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
)}
{canPreviewOrCopyLink && (
<DropdownMenuItem>
<button
@@ -5,9 +5,14 @@ import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
export const GoBackButton = ({ url }: { url?: string }) => {
interface GoBackButtonProps {
url?: string;
}
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
const router = useRouter();
const { t } = useTranslation();
return (
<Button
size="sm"
@@ -17,6 +22,7 @@ export const GoBackButton = ({ url }: { url?: string }) => {
router.push(url);
return;
}
router.back();
}}>
<ArrowLeftIcon />
@@ -34,7 +34,6 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ workspac
<IdBadge
id={workspace.legacyEnvironmentId}
label={t("workspace.app-connection.environment_id_legacy")}
copyDisabled
/>
)}
<IdBadge id={WEBAPP_URL} label={t("workspace.app-connection.webapp_url")} />
+3 -1
View File
@@ -224,7 +224,9 @@ test.describe("Survey overview", () => {
});
await page.locator("[data-testid='survey-dropdown-trigger']").click();
await expect(page.getByText("Duplicate", { exact: true })).toHaveCount(0);
// Duplicate stays visible for users who can manage surveys (works on drafts too —
// it creates another draft via copySurveyToOtherWorkspaceAction).
await expect(page.getByTestId("duplicate-survey")).toBeVisible();
await expect(page.getByText("Copy...", { exact: true })).toHaveCount(0);
await expect(page.getByText("Preview", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("copy-link")).toHaveCount(0);
+2 -5
View File
@@ -2,14 +2,11 @@ dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 16.4.16
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 20.11.2
- name: gateway-helm
repository: oci://docker.io/envoyproxy
version: v1.7.1
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 20.11.2
digest: sha256:d93a29cb9cbef513db410ded5aa199f219c5b25ce1870e1e049dc470acc34235
generated: "2026-04-08T00:33:09.875832+05:30"
digest: sha256:078652b37649ba5bb72f7463709356c08f464cb989270b97d095b8243c0e58b9
generated: "2026-05-21T12:52:44.141547+05:30"
+3 -7
View File
@@ -1,6 +1,6 @@
apiVersion: v2
name: formbricks
description: A Helm chart for Formbricks with PostgreSQL, Redis
description: A Helm chart for Formbricks with PostgreSQL, Valkey
type: application
@@ -8,14 +8,14 @@ type: application
version: 0.0.0-dev
# This is the version number of the application being deployed.
appVersion: "3.7.0"
appVersion: "5.0.0-rc.1"
icon: https://formbricks.com/favicon.ico
keywords:
- formbricks
- postgresql
- redis
- valkey
home: https://formbricks.com/docs/self-hosting/setup/kubernetes
maintainers:
@@ -27,10 +27,6 @@ dependencies:
version: "16.4.16"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
- name: redis
version: 20.11.2
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled
- name: gateway-helm
alias: envoy
version: "v1.7.1"
+237 -216
View File
@@ -1,8 +1,8 @@
# formbricks
![Version: 0.0.0-dev](https://img.shields.io/badge/Version-0.0.0--dev-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.7.0](https://img.shields.io/badge/AppVersion-3.7.0-informational?style=flat-square)
![Version: 0.0.0-dev](https://img.shields.io/badge/Version-0.0.0--dev-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 5.0.0-rc.1](https://img.shields.io/badge/AppVersion-5.0.0--rc.1-informational?style=flat-square)
A Helm chart for Formbricks with PostgreSQL, Redis
A Helm chart for Formbricks with PostgreSQL, Valkey
**Homepage:** <https://formbricks.com/docs/self-hosting/setup/kubernetes>
@@ -17,9 +17,8 @@ A Helm chart for Formbricks with PostgreSQL, Redis
| Repository | Name | Version |
| ---------------------------------------- | ------------ | ------- |
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.4.16 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
| oci://docker.io/envoyproxy | gateway-helm | v1.7.1 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
| oci://registry-1.docker.io/bitnamicharts | envoyRedis | 20.11.2 |
## Envoy bundle modes
@@ -32,7 +31,7 @@ rate limiting.
Gateway API CRDs plus an Envoy Gateway controller compatible with
`envoy.config.envoyGateway.gateway.controllerName`.
- `envoyRedis.enabled=true` deploys a dedicated Redis replication + Sentinel bundle for Envoy RLS. It is intentionally
separate from the existing app `redis` dependency.
separate from the bundled app Valkey deployment.
- The bundled controller reads its Redis backend from `envoy.config.envoyGateway.rateLimit.backend.redis.url`.
If you enable Redis authentication or override `envoyRedis.fullnameOverride`, set that URL explicitly so the
controller points at the correct backend.
@@ -55,7 +54,8 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
when using the default release name.
- For an external Cube, set `cube.enabled: false` and point `deployment.env.CUBEJS_API_URL` at your
endpoint.
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- The generated app secret supplies `CUBEJS_API_SECRET` by default. If you disable generated secrets,
provide it through your existing secret management flow.
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
@@ -92,213 +92,234 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
## Values
| Key | Type | Default | Description |
| ------------------------------------------------------------------ | ------ | --------------------------------- | ----------- |
| autoscaling.additionalLabels | object | `{}` | |
| autoscaling.annotations | object | `{}` | |
| autoscaling.behavior.scaleDown.policies[0].periodSeconds | int | `120` | |
| autoscaling.behavior.scaleDown.policies[0].type | string | `"Pods"` | |
| autoscaling.behavior.scaleDown.policies[0].value | int | `1` | |
| autoscaling.behavior.scaleDown.stabilizationWindowSeconds | int | `300` | |
| autoscaling.behavior.scaleUp.policies[0].periodSeconds | int | `60` | |
| autoscaling.behavior.scaleUp.policies[0].type | string | `"Pods"` | |
| autoscaling.behavior.scaleUp.policies[0].value | int | `2` | |
| autoscaling.behavior.scaleUp.stabilizationWindowSeconds | int | `60` | |
| autoscaling.enabled | bool | `true` | |
| autoscaling.maxReplicas | int | `10` | |
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[0].type | string | `"Resource"` | |
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[1].type | string | `"Resource"` | |
| autoscaling.minReplicas | int | `1` | |
| componentOverride | string | `""` | |
| deployment.additionalLabels | object | `{}` | |
| deployment.additionalPodAnnotations | object | `{}` | |
| deployment.additionalPodLabels | object | `{}` | |
| deployment.affinity | object | `{}` | |
| deployment.annotations | object | `{}` | |
| deployment.args | list | `[]` | |
| deployment.command | list | `[]` | |
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
| deployment.env | object | `{}` | |
| deployment.envFrom | string | `nil` | |
| deployment.image.digest | string | `""` | |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.image.tag | string | `""` | |
| deployment.imagePullSecrets | string | `""` | |
| deployment.nodeSelector | object | `{}` | |
| deployment.ports.http.containerPort | int | `3000` | |
| deployment.ports.http.exposed | bool | `true` | |
| deployment.ports.http.protocol | string | `"TCP"` | |
| deployment.ports.metrics.containerPort | int | `9464` | |
| deployment.ports.metrics.exposed | bool | `true` | |
| deployment.ports.metrics.protocol | string | `"TCP"` | |
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
| deployment.reloadOnChange | bool | `false` | |
| deployment.replicas | int | `1` | |
| deployment.resources.limits.memory | string | `"2Gi"` | |
| deployment.resources.requests.cpu | string | `"1"` | |
| deployment.resources.requests.memory | string | `"1Gi"` | |
| deployment.revisionHistoryLimit | int | `2` | |
| deployment.securityContext | object | `{}` | |
| deployment.strategy.type | string | `"RollingUpdate"` | |
| deployment.tolerations | list | `[]` | |
| deployment.topologySpreadConstraints | list | `[]` | |
| enterprise.enabled | bool | `false` | |
| enterprise.licenseKey | string | `""` | |
| externalSecret.enabled | bool | `false` | |
| externalSecret.files | object | `{}` | |
| externalSecret.refreshInterval | string | `"1h"` | |
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| formbricks.publicUrl | string | `""` | |
| formbricks.webappUrl | string | `""` | |
| hub.autoscaling.enabled | bool | `false` | |
| hub.autoscaling.maxReplicas | int | `3` | |
| hub.autoscaling.minReplicas | int | `1` | |
| hub.enabled | bool | `true` | |
| hub.embeddings.auth.enabled | bool | `true` | |
| hub.embeddings.auth.existingSecret | string | `""` | |
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
| hub.embeddings.autoscaling.enabled | bool | `false` | |
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
| hub.embeddings.enabled | bool | `false` | |
| hub.embeddings.extraArgs | list | `["--dtype","float16"]` | Additional args appended to the generated TEI args. |
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
| hub.embeddings.huggingFace.token | string | `""` | |
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
| hub.embeddings.maxConcurrent | string | `"5"` | |
| hub.embeddings.model | string | `"Alibaba-NLP/gte-multilingual-base"` | |
| hub.embeddings.persistence.enabled | bool | `true` | |
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
| hub.embeddings.persistence.size | string | `"10Gi"` | |
| hub.embeddings.pdb.enabled | bool | `false` | |
| hub.embeddings.port | int | `8080` | |
| hub.embeddings.prometheusPort | int | `9000` | |
| hub.embeddings.replicas | int | `1` | |
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
| hub.embeddings.runtime | string | `"tei"` | |
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
| hub.embeddings.service.port | int | `8080` | |
| hub.embeddings.service.type | string | `"ClusterIP"` | |
| hub.env | object | `{}` | |
| hub.existingSecret | string | `""` | |
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
| hub.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.image.repository | string | `"ghcr.io/formbricks/hub"` | |
| hub.image.tag | string | `"0.3.0"` | Fallback when digest is empty. |
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| hub.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
| hub.resources.requests.cpu | string | `"100m"` | |
| hub.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.autoscaling.enabled | bool | `false` | |
| hub.worker.autoscaling.maxReplicas | int | `5` | |
| hub.worker.autoscaling.minReplicas | int | `1` | |
| hub.worker.enabled | bool | `true` | |
| hub.worker.env | object | `{}` | |
| hub.worker.pdb.enabled | bool | `false` | |
| hub.worker.replicas | int | `1` | |
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
| hub.worker.resources.requests.cpu | string | `"100m"` | |
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.waitForApi.enabled | bool | `true` | |
| hub.worker.waitForApi.maxAttempts | int | `120` | 120 attempts at 5s intervals = 10 minutes. |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
| ingress.hosts[0].paths[0].path | string | `"/"` | |
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
| ingress.ingressClassName | string | `"alb"` | |
| migration.annotations | object | `{}` | |
| migration.backoffLimit | int | `3` | |
| migration.enabled | bool | `true` | |
| migration.resources.limits.memory | string | `"512Mi"` | |
| migration.resources.requests.cpu | string | `"100m"` | |
| migration.resources.requests.memory | string | `"256Mi"` | |
| migration.ttlSecondsAfterFinished | int | `300` | |
| nameOverride | string | `""` | |
| partOfOverride | string | `""` | |
| pdb.additionalLabels | object | `{}` | |
| pdb.annotations | object | `{}` | |
| pdb.enabled | bool | `true` | |
| pdb.minAvailable | int | `1` | |
| postgresql.auth.database | string | `"formbricks"` | |
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
| postgresql.auth.username | string | `"formbricks"` | |
| postgresql.enabled | bool | `true` | |
| postgresql.externalDatabaseUrl | string | `""` | |
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
| postgresql.global.security.allowInsecureImages | bool | `true` | |
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
| postgresql.image.tag | string | `"pg17"` | |
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
| postgresql.primary.persistence.enabled | bool | `true` | |
| postgresql.primary.persistence.size | string | `"10Gi"` | |
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
| rbac.enabled | bool | `false` | |
| rbac.serviceAccount.additionalLabels | object | `{}` | |
| rbac.serviceAccount.annotations | object | `{}` | |
| rbac.serviceAccount.enabled | bool | `false` | |
| rbac.serviceAccount.name | string | `""` | |
| redis.architecture | string | `"standalone"` | |
| redis.auth.enabled | bool | `true` | |
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
| redis.enabled | bool | `true` | |
| redis.externalRedisUrl | string | `""` | |
| redis.fullnameOverride | string | `"formbricks-redis"` | |
| redis.master.persistence.enabled | bool | `true` | |
| redis.networkPolicy.enabled | bool | `false` | |
| secret.enabled | bool | `true` | |
| service.additionalLabels | object | `{}` | |
| service.annotations | object | `{}` | |
| service.enabled | bool | `true` | |
| service.ports | list | `[]` | |
| service.type | string | `"ClusterIP"` | |
| serviceMonitor.additionalLabels | string | `nil` | |
| serviceMonitor.annotations | string | `nil` | |
| serviceMonitor.enabled | bool | `true` | |
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
| Key | Type | Default | Description |
| ------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------- | --------------------------------------------------------- |
| autoscaling.additionalLabels | object | `{}` | |
| autoscaling.annotations | object | `{}` | |
| autoscaling.behavior.scaleDown.policies[0].periodSeconds | int | `120` | |
| autoscaling.behavior.scaleDown.policies[0].type | string | `"Pods"` | |
| autoscaling.behavior.scaleDown.policies[0].value | int | `1` | |
| autoscaling.behavior.scaleDown.stabilizationWindowSeconds | int | `300` | |
| autoscaling.behavior.scaleUp.policies[0].periodSeconds | int | `60` | |
| autoscaling.behavior.scaleUp.policies[0].type | string | `"Pods"` | |
| autoscaling.behavior.scaleUp.policies[0].value | int | `2` | |
| autoscaling.behavior.scaleUp.stabilizationWindowSeconds | int | `60` | |
| autoscaling.enabled | bool | `true` | |
| autoscaling.maxReplicas | int | `10` | |
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[0].type | string | `"Resource"` | |
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[1].type | string | `"Resource"` | |
| autoscaling.minReplicas | int | `1` | |
| componentOverride | string | `""` | |
| deployment.additionalLabels | object | `{}` | |
| deployment.additionalPodAnnotations | object | `{}` | |
| deployment.additionalPodLabels | object | `{}` | |
| deployment.affinity | object | `{}` | |
| deployment.annotations | object | `{}` | |
| deployment.args | list | `[]` | |
| deployment.command | list | `[]` | |
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
| deployment.env | object | `{}` | |
| deployment.envFrom | string | `nil` | |
| deployment.image.digest | string | `""` | |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.image.tag | string | `""` | |
| deployment.imagePullSecrets | string | `""` | |
| deployment.nodeSelector | object | `{}` | |
| deployment.ports.http.containerPort | int | `3000` | |
| deployment.ports.http.exposed | bool | `true` | |
| deployment.ports.http.protocol | string | `"TCP"` | |
| deployment.ports.metrics.containerPort | int | `9464` | |
| deployment.ports.metrics.exposed | bool | `true` | |
| deployment.ports.metrics.protocol | string | `"TCP"` | |
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
| deployment.reloadOnChange | bool | `false` | |
| deployment.replicas | int | `1` | |
| deployment.resources.limits.memory | string | `"2Gi"` | |
| deployment.resources.requests.cpu | string | `"1"` | |
| deployment.resources.requests.memory | string | `"1Gi"` | |
| deployment.revisionHistoryLimit | int | `2` | |
| deployment.securityContext | object | `{}` | |
| deployment.strategy.type | string | `"RollingUpdate"` | |
| deployment.tolerations | list | `[]` | |
| deployment.topologySpreadConstraints | list | `[]` | |
| enterprise.enabled | bool | `false` | |
| enterprise.licenseKey | string | `""` | |
| externalSecret.enabled | bool | `false` | |
| externalSecret.files | object | `{}` | |
| externalSecret.refreshInterval | string | `"1h"` | |
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| formbricks.publicUrl | string | `""` | |
| formbricks.webappUrl | string | `""` | |
| hub.autoscaling.enabled | bool | `false` | |
| hub.autoscaling.maxReplicas | int | `3` | |
| hub.autoscaling.minReplicas | int | `1` | |
| hub.enabled | bool | `true` | |
| hub.embeddings.auth.enabled | bool | `true` | |
| hub.embeddings.auth.existingSecret | string | `""` | |
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
| hub.embeddings.autoscaling.enabled | bool | `false` | |
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
| hub.embeddings.enabled | bool | `false` | |
| hub.embeddings.extraArgs | list | `["--dtype","float16"]` | Additional args appended to the generated TEI args. |
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
| hub.embeddings.huggingFace.token | string | `""` | |
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
| hub.embeddings.maxConcurrent | string | `"5"` | |
| hub.embeddings.model | string | `"Alibaba-NLP/gte-multilingual-base"` | |
| hub.embeddings.persistence.enabled | bool | `true` | |
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
| hub.embeddings.persistence.size | string | `"10Gi"` | |
| hub.embeddings.pdb.enabled | bool | `false` | |
| hub.embeddings.port | int | `8080` | |
| hub.embeddings.prometheusPort | int | `9000` | |
| hub.embeddings.replicas | int | `1` | |
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
| hub.embeddings.runtime | string | `"tei"` | |
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
| hub.embeddings.service.port | int | `8080` | |
| hub.embeddings.service.type | string | `"ClusterIP"` | |
| hub.env | object | `{}` | |
| hub.existingSecret | string | `""` | |
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
| hub.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.image.repository | string | `"ghcr.io/formbricks/hub"` | |
| hub.image.tag | string | `"0.3.0"` | Fallback when digest is empty. |
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| hub.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
| hub.resources.requests.cpu | string | `"100m"` | |
| hub.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.autoscaling.enabled | bool | `false` | |
| hub.worker.autoscaling.maxReplicas | int | `5` | |
| hub.worker.autoscaling.minReplicas | int | `1` | |
| hub.worker.enabled | bool | `true` | |
| hub.worker.env | object | `{}` | |
| hub.worker.pdb.enabled | bool | `false` | |
| hub.worker.replicas | int | `1` | |
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
| hub.worker.resources.requests.cpu | string | `"100m"` | |
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.waitForApi.enabled | bool | `true` | |
| hub.worker.waitForApi.maxAttempts | int | `120` | 120 attempts at 5s intervals = 10 minutes. |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
| ingress.hosts[0].paths[0].path | string | `"/"` | |
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
| ingress.ingressClassName | string | `"alb"` | |
| migration.annotations | object | `{}` | |
| migration.backoffLimit | int | `3` | |
| migration.enabled | bool | `true` | |
| migration.resources.limits.memory | string | `"512Mi"` | |
| migration.resources.requests.cpu | string | `"100m"` | |
| migration.resources.requests.memory | string | `"256Mi"` | |
| migration.ttlSecondsAfterFinished | int | `300` | |
| nameOverride | string | `""` | |
| partOfOverride | string | `""` | |
| pdb.additionalLabels | object | `{}` | |
| pdb.annotations | object | `{}` | |
| pdb.enabled | bool | `true` | |
| pdb.minAvailable | int | `1` | |
| postgresql.auth.database | string | `"formbricks"` | |
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
| postgresql.auth.username | string | `"formbricks"` | |
| postgresql.enabled | bool | `true` | |
| postgresql.externalDatabaseUrl | string | `""` | |
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
| postgresql.global.security.allowInsecureImages | bool | `true` | |
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
| postgresql.image.tag | string | `"pg17"` | |
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
| postgresql.primary.persistence.enabled | bool | `true` | |
| postgresql.primary.persistence.size | string | `"10Gi"` | |
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
| rbac.enabled | bool | `false` | |
| rbac.serviceAccount.additionalLabels | object | `{}` | |
| rbac.serviceAccount.annotations | object | `{}` | |
| rbac.serviceAccount.enabled | bool | `false` | |
| rbac.serviceAccount.name | string | `""` | |
| redis.architecture | string | `"standalone"` | |
| redis.auth.enabled | bool | `true` | |
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
| redis.enabled | bool | `true` | |
| redis.externalRedisUrl | string | `""` | |
| redis.fullnameOverride | string | `"formbricks-redis"` | |
| redis.image.digest | string | `"sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d"` | |
| redis.image.pullPolicy | string | `"IfNotPresent"` | |
| redis.image.repository | string | `"valkey/valkey"` | |
| redis.image.tag | string | `""` | |
| redis.master.affinity | object | `{}` | |
| redis.master.containerSecurityContext | object | `{}` | |
| redis.master.nodeSelector | object | `{}` | |
| redis.master.pdb.enabled | bool | `true` | |
| redis.master.pdb.maxUnavailable | int | `1` | |
| redis.master.pdb.minAvailable | string | `""` | |
| redis.master.persistence.accessModes[0] | string | `"ReadWriteOnce"` | |
| redis.master.persistence.enabled | bool | `true` | |
| redis.master.persistence.size | string | `"8Gi"` | |
| redis.master.persistence.storageClass | string | `""` | |
| redis.master.podAnnotations | object | `{}` | |
| redis.master.podSecurityContext | object | `{}` | |
| redis.master.resources.limits.cpu | string | `"150m"` | |
| redis.master.resources.limits.memory | string | `"192Mi"` | |
| redis.master.resources.requests.cpu | string | `"100m"` | |
| redis.master.resources.requests.memory | string | `"128Mi"` | |
| redis.master.tolerations | list | `[]` | |
| redis.master.topologySpreadConstraints | list | `[]` | |
| redis.networkPolicy.enabled | bool | `false` | |
| secret.enabled | bool | `true` | |
| service.additionalLabels | object | `{}` | |
| service.annotations | object | `{}` | |
| service.enabled | bool | `true` | |
| service.ports | list | `[]` | |
| service.type | string | `"ClusterIP"` | |
| serviceMonitor.additionalLabels | string | `nil` | |
| serviceMonitor.annotations | string | `nil` | |
| serviceMonitor.enabled | bool | `true` | |
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
+139 -17
View File
@@ -9,46 +9,120 @@ cube(`FeedbackRecords`, {
description: `Total number of feedback responses`,
},
uniqueRespondents: {
type: `countDistinct`,
sql: `${CUBE}.user_id`,
description: `Number of unique users who provided feedback`,
},
uniqueResponses: {
type: `countDistinct`,
sql: `${CUBE}.submission_id`,
description: `Number of unique survey submissions (a submission can produce multiple feedback records)`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
description: `Number of NPS promoters (score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
description: `Number of NPS detractors (score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
description: `Number of NPS passives (score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
(COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6 THEN 1 END)::numeric)
/ COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
},
averageScore: {
npsAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
filters: [{ sql: `${CUBE}.field_type = 'nps'` }],
description: `Average NPS rating (0-10)`,
},
csatCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL` }],
description: `Number of answered CSAT responses (dismissed responses excluded).`,
},
csatSatisfiedCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4` }],
description: `Number of satisfied CSAT responses (top-2-box on the 1-5 scale)`,
},
csatDissatisfiedCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number BETWEEN 1 AND 2` }],
description: `Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)`,
},
csatNeutralCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number = 3` }],
description: `Number of neutral CSAT responses (middle box on the 1-5 scale)`,
},
csatScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
ELSE ROUND(
(
COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4 THEN 1 END)::numeric
/ COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
) * 100,
2
)
END
`,
description: `CSAT Score: % of answered CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale). NULL when there are no answered CSAT responses.`,
},
csatAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
filters: [{ sql: `${CUBE}.field_type = 'csat'` }],
description: `Average CSAT rating (1-5)`,
},
cesCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'ces' AND ${CUBE}.value_number IS NOT NULL` }],
description: `Number of answered CES responses (dismissed responses excluded).`,
},
cesAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
filters: [{ sql: `${CUBE}.field_type = 'ces'` }],
description: `Average CES rating (scale is 1-5 or 1-7 depending on the question)`,
},
},
@@ -77,22 +151,70 @@ cube(`FeedbackRecords`, {
description: `Type of feedback field (e.g., nps, text, rating)`,
},
fieldLabel: {
sql: `field_label`,
type: `string`,
description: `Human-readable label of the question/field (e.g., "How satisfied are you with support?")`,
},
fieldGroupLabel: {
sql: `field_group_label`,
type: `string`,
description: `Label of the parent composite question for matrix/ranking rows`,
},
language: {
sql: `language`,
type: `string`,
description: `Response language code (e.g., "en", "de"). NULL when language is "default".`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
createdAt: {
sql: `created_at`,
type: `time`,
description: `Timestamp when the feedback record was created in Hub`,
},
updatedAt: {
sql: `updated_at`,
type: `time`,
description: `Timestamp when the feedback record was last updated in Hub`,
},
valueNumber: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
description: `Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, generic number). Pair with a fieldType filter to keep scales consistent.`,
},
valueText: {
sql: `value_text`,
type: `string`,
description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`,
},
valueBoolean: {
sql: `value_boolean`,
type: `boolean`,
description: `Boolean answer value (yes/no questions). Pair with a fieldType filter.`,
},
valueDate: {
sql: `value_date`,
type: `time`,
description: `Date answer value (e.g., "preferred meeting date"). Pair with a fieldType filter.`,
},
responseId: {
sql: `response_id`,
sql: `submission_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
},
userId: {
+1 -1
View File
@@ -69,7 +69,7 @@ Redis Access:
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.REDIS_PASSWORD}" | base64 --decode
```
Connection details:
- **Host**: `{{ include "formbricks.name" . }}-redis-master`
- **Host**: `{{ include "formbricks.redisMasterName" . }}`
- **Port**: `6379`
{{- else if .Values.redis.externalRedisUrl }}
You're using an external Redis instance.
+49
View File
@@ -92,6 +92,26 @@ This function allows rendering values dynamically.
{{- end }}
{{- end }}
{{/*
Render a Kubernetes EnvVar from chart env maps.
Scalar values become quoted string values. Map values are rendered as EnvVar fields,
which keeps advanced forms such as valueFrom supported.
*/}}
{{- define "formbricks.envVarValue" -}}
{{- $value := .value -}}
{{- if kindIs "map" $value -}}
{{- include "formbricks.tplvalues.render" (dict "value" $value "context" .context) -}}
{{- else if kindIs "invalid" $value -}}
value: ""
{{- else -}}
value: {{ include "formbricks.tplvalues.render" (dict "value" (toString $value) "context" .context) | trim | quote }}
{{- end -}}
{{- end }}
{{- define "formbricks.envVar" -}}
- name: {{ include "formbricks.tplvalues.render" (dict "value" .name "context" .context) }}
{{- include "formbricks.envVarValue" (dict "value" .value "context" .context) | nindent 2 }}
{{- end }}
{{/*
Allow the release namespace to be overridden.
@@ -105,6 +125,26 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
{{- end }}
{{- define "formbricks.redisName" -}}
{{- .Values.redis.fullnameOverride | default (printf "%s-redis" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisMasterName" -}}
{{- printf "%s-master" (include "formbricks.redisName" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisHeadlessName" -}}
{{- printf "%s-headless" (include "formbricks.redisName" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisImage" -}}
{{- if .Values.redis.image.digest -}}
{{- printf "%s@%s" .Values.redis.image.repository .Values.redis.image.digest -}}
{{- else -}}
{{- printf "%s:%s" .Values.redis.image.repository .Values.redis.image.tag -}}
{{- end -}}
{{- end }}
{{- define "formbricks.hubSecretName" -}}
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
{{- end }}
@@ -289,6 +329,15 @@ true
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.cubejsApiSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.envoy.gatewayClassName" -}}
{{- if .Values.envoy.formbricks.gatewayClass.name -}}
{{- .Values.envoy.formbricks.gatewayClass.name | trunc 63 | trimSuffix "-" -}}
@@ -70,8 +70,12 @@ spec:
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.cube.envFrom }}
{{- if or .Values.cube.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
envFrom:
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
- secretRef:
name: {{ template "formbricks.name" . }}-app-secrets
{{- end }}
{{- range $value := .Values.cube.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
@@ -97,12 +101,7 @@ spec:
{{- end }}
env:
{{- range $key, $value := .Values.cube.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
{{- else }}
{{- toYaml $value | nindent 14 }}
{{- end }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
volumeMounts:
- name: cube-config
+1 -6
View File
@@ -136,12 +136,7 @@ spec:
value: "http://{{ include "formbricks.hubname" . }}:8080"
{{- end }}
{{- range $key, $value := .Values.deployment.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
{{- else }}
{{- toYaml $value | nindent 14 }}
{{- end }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
{{- if .Values.deployment.resources }}
resources:
@@ -73,8 +73,7 @@ spec:
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" .Values.hub.env) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
{{- end }}
{{- if .Values.hub.resources }}
@@ -129,8 +129,7 @@ spec:
{{- end }}
{{- range $key, $value := .Values.hub.embeddings.env }}
{{- if not (or (and $.Values.hub.embeddings.auth.enabled (eq $key "API_KEY")) (and (or $.Values.hub.embeddings.huggingFace.existingSecret $.Values.hub.embeddings.huggingFace.token) (eq $key "HF_TOKEN"))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
{{- end }}
{{- end }}
@@ -90,14 +90,12 @@ spec:
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $workerEnv) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if and (not (hasKey $.Values.hub.worker.env $key)) (not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key)))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
{{- end }}
{{- range $key, $value := .Values.hub.worker.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
{{- end }}
{{- end }}
@@ -82,12 +82,7 @@ spec:
{{- end }}
env:
{{- range $key, $value := .Values.deployment.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
{{- else }}
{{- toYaml $value | nindent 14 }}
{{- end }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
{{- end }}
{{- if .Values.migration.resources }}
resources:
@@ -0,0 +1,17 @@
{{- if .Values.redis.enabled }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "formbricks.redisName" . }}-configuration
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
data:
valkey.conf: |-
{{ .Values.redis.commonConfiguration | nindent 4 }}
{{- end }}
@@ -0,0 +1,28 @@
{{- if and .Values.redis.enabled .Values.redis.networkPolicy.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "formbricks.redisMasterName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {}
ports:
- port: redis
protocol: TCP
{{- end }}
@@ -0,0 +1,25 @@
{{- if and .Values.redis.enabled .Values.redis.master.pdb.enabled }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.redisMasterName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if .Values.redis.master.pdb.minAvailable }}
minAvailable: {{ .Values.redis.master.pdb.minAvailable }}
{{- else }}
maxUnavailable: {{ .Values.redis.master.pdb.maxUnavailable | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
{{- end }}
@@ -0,0 +1,50 @@
{{- if .Values.redis.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.redisHeadlessName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
type: ClusterIP
clusterIP: None
publishNotReadyAddresses: true
selector:
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.redisMasterName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
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.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
protocol: TCP
{{- end }}
@@ -0,0 +1,147 @@
{{- if .Values.redis.enabled }}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "formbricks.redisMasterName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
replicas: 1
serviceName: {{ include "formbricks.redisHeadlessName" . }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
annotations:
checksum/config: {{ include (print $.Template.BasePath "/redis-configmap.yaml") . | sha256sum }}
{{- with .Values.redis.master.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.redis.master.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.master.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.master.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.master.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.master.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: valkey
image: {{ include "formbricks.redisImage" . }}
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
{{- with .Values.redis.master.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
command:
- /bin/sh
- -c
- |
set -eu
{{- if .Values.redis.auth.enabled }}
exec valkey-server /etc/valkey/valkey.conf --requirepass "$VALKEY_PASSWORD"
{{- else }}
exec valkey-server /etc/valkey/valkey.conf
{{- end }}
ports:
- name: redis
containerPort: 6379
protocol: TCP
{{- if .Values.redis.auth.enabled }}
env:
- name: VALKEY_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.auth.existingSecret | default (include "formbricks.appSecretName" .) }}
key: {{ .Values.redis.auth.existingSecretPasswordKey | default "REDIS_PASSWORD" }}
- name: REDISCLI_AUTH
valueFrom:
secretKeyRef:
name: {{ .Values.redis.auth.existingSecret | default (include "formbricks.appSecretName" .) }}
key: {{ .Values.redis.auth.existingSecretPasswordKey | default "REDIS_PASSWORD" }}
{{- end }}
livenessProbe:
exec:
command:
- /bin/sh
- -c
- valkey-cli ping | grep -q PONG
initialDelaySeconds: 20
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 5
readinessProbe:
exec:
command:
- /bin/sh
- -c
- valkey-cli ping | grep -q PONG
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 5
{{- with .Values.redis.master.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: redis-config
mountPath: /etc/valkey
readOnly: true
- name: redis-data
mountPath: /data
volumes:
- name: redis-config
configMap:
name: {{ include "formbricks.redisName" . }}-configuration
{{- if .Values.redis.master.persistence.enabled }}
volumeClaimTemplates:
- apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.redisName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
accessModes:
{{- toYaml .Values.redis.master.persistence.accessModes | nindent 10 }}
{{- with .Values.redis.master.persistence.storageClass }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.redis.master.persistence.size | quote }}
{{- else }}
- name: redis-data
emptyDir: {}
{{- end }}
{{- end }}
@@ -0,0 +1,3 @@
{{- if and .Values.redis.enabled (ne (.Values.redis.architecture | toString) "standalone") -}}
{{- fail "redis.architecture currently supports only standalone with the bundled Valkey deployment. Use redis.externalRedisUrl and redis.enabled=false for external HA Redis/Valkey." -}}
{{- end -}}
+7 -3
View File
@@ -5,6 +5,7 @@
{{- $redisPassword := include "formbricks.redisPassword" . }}
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
{{- $hubApiKey := include "formbricks.hubApiKey" . }}
{{- $cubejsApiSecret := include "formbricks.cubejsApiSecret" . }}
---
apiVersion: v1
kind: Secret
@@ -19,8 +20,10 @@ data:
{{- if .Values.formbricks.publicUrl }}
PUBLIC_URL: {{ .Values.formbricks.publicUrl | b64enc }}
{{- end }}
{{- if .Values.redis.enabled }}
REDIS_URL: {{ printf "redis://:%s@formbricks-redis-master:6379" $redisPassword | b64enc }}
{{- if and .Values.redis.enabled .Values.redis.auth.enabled }}
REDIS_URL: {{ printf "redis://:%s@%s:6379" $redisPassword (include "formbricks.redisMasterName" .) | b64enc }}
{{- else if .Values.redis.enabled }}
REDIS_URL: {{ printf "redis://%s:6379" (include "formbricks.redisMasterName" .) | b64enc }}
{{- else }}
REDIS_URL: {{ .Values.redis.externalRedisUrl | b64enc }}
{{- end }}
@@ -31,13 +34,14 @@ data:
{{- end }}
HUB_API_KEY: {{ $hubApiKey | b64enc }}
credential: {{ printf "Bearer %s" $hubApiKey | b64enc }}
CUBEJS_API_SECRET: {{ $cubejsApiSecret | b64enc }}
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
{{- if and (.Values.enterprise.licenseKey) (ne .Values.enterprise.licenseKey "") }}
ENTERPRISE_LICENSE_KEY: {{ .Values.enterprise.licenseKey | b64enc }}
{{- end }}
{{- if .Values.redis.enabled }}
{{- if and .Values.redis.enabled .Values.redis.auth.enabled }}
REDIS_PASSWORD: {{ $redisPassword | b64enc }}
{{- end }}
{{- if .Values.postgresql.enabled }}
+32 -4
View File
@@ -513,15 +513,20 @@ envoyRedis:
enabled: false
##########################################################
# Redis Configuration
# Valkey Configuration
##########################################################
redis:
enabled: true # Enable/disable Redis
enabled: true # Enable/disable bundled Valkey
# Use this to point Formbricks jobs and cache traffic at an existing Redis/Valkey deployment.
# External Redis instances must also be configured with `maxmemory-policy noeviction`
# in their redis.conf/valkey.conf (or equivalent managed-service setting) to avoid BullMQ job loss.
externalRedisUrl: ""
fullnameOverride: "formbricks-redis"
image:
repository: "valkey/valkey"
tag: ""
digest: "sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d"
pullPolicy: IfNotPresent
architecture: standalone
commonConfiguration: |-
appendonly yes
@@ -534,8 +539,30 @@ redis:
networkPolicy:
enabled: false
master:
resources:
limits:
cpu: 150m
memory: 192Mi
requests:
cpu: 100m
memory: 128Mi
podSecurityContext: {}
containerSecurityContext: {}
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
persistence:
enabled: true
size: 8Gi
storageClass: ""
accessModes:
- ReadWriteOnce
pdb:
enabled: true
minAvailable: ""
maxUnavailable: 1
##########################################################
# Service Monitor to collect Prometheus metrices
@@ -580,8 +607,9 @@ cube:
type: ClusterIP
port: 4000
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
# through envFrom or another secret-management flow.
# The generated app secret supplies CUBEJS_API_SECRET when secret.enabled=true.
# Secret values such as CUBEJS_DB_* should be supplied through envFrom or another secret-management
# flow.
envFrom: []
env:
+1 -1
View File
@@ -33,7 +33,7 @@ That's it! After running the command and providing the required information, vis
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and the bundled Cube service. Hub and Cube share the same database as Formbricks by default and both start as part of the baseline `docker compose up`.
- **Migrations**: A `hub-migrate` service runs Hub's 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` and `CUBEJS_API_SECRET` (both required). `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the compose network. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` and `CUBEJS_API_SECRET` (both required). Run `docker compose config >/dev/null` after creating `.env`; it fails if either value is missing or empty. `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the compose network. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
- **Development** (`docker-compose.dev.yml`): Hub uses a dedicated local `hub` database and `HUB_API_KEY` defaults to `dev-api-key`. The dev stack starts `hub` plus `hub-worker`; set `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, and any provider credentials in the repo root `.env` to enable Hub embeddings locally. See the [Hub embeddings environment reference](https://hub.formbricks.com/reference/environment-variables/#embeddings) for provider-specific values. Cube starts with the dev stack, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub`, `hub-worker`, and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
In development, Hub is exposed locally on port **8080** and Cube on **4000** (with the Cube playground on **4001**). In production Docker Compose, both stay internal to the compose network at `http://hub:8080` and `http://cube:4000`.
+2 -2
View File
@@ -40,7 +40,7 @@ x-environment: &environment
# Cube semantic-layer API used by Formbricks analytics. Required.
CUBEJS_API_URL: ${CUBEJS_API_URL:-http://cube:4000}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube}
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube}
@@ -312,7 +312,7 @@ services:
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube}
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube}
CUBEJS_DEFAULT_API_SCOPES: meta,data
+16 -6
View File
@@ -18,8 +18,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
<Info>
Starting with Formbricks v5, the production Docker Compose stack includes Formbricks Hub and Cube as part of
the baseline. Generate `HUB_API_KEY` and `CUBEJS_API_SECRET` during setup, keep `HUB_API_URL` at its
internal default unless Hub runs elsewhere, and use the [migration guide](/self-hosting/advanced/migration#v5)
when upgrading an existing 4.x instance.
internal default unless Hub runs elsewhere, and use the [migration
guide](/self-hosting/advanced/migration#v5) when upgrading an existing 4.x instance.
</Info>
## Start
@@ -56,6 +56,16 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
EOF
```
1. **Validate the Docker Compose Configuration**
Confirm Docker Compose can resolve the required Hub and Cube variables before starting the stack:
```bash
docker compose config >/dev/null
```
This command fails if `HUB_API_KEY` or `CUBEJS_API_SECRET` is missing or empty in `.env`.
1. **Generate NextAuth Secret**
You need a NextAuth secret for session signing and encryption. Run one of the commands below based on your operating system:
@@ -122,8 +132,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
<Info>
The bundled production stack already sets <code>HUB_API_URL</code> to <code>http://hub:8080</code>. Only
change that value if your Formbricks app needs to reach Hub at a different address. If your deployment also
resolves Compose variables from a shell environment or <code>.env</code> file, keep the same
change that value if your Formbricks app needs to reach Hub at a different address. If your deployment
also resolves Compose variables from a shell environment or <code>.env</code> file, keep the same
<code>HUB_API_KEY</code> available there as well.
</Info>
@@ -143,8 +153,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
Once the setup is running, open [**http://localhost:3000**](http://localhost:3000) in your browser to access Formbricks. The first time you visit, you'll see a setup wizard. Follow the steps to create your first user and start using Formbricks.
<Note>
The bundled Docker stack keeps Formbricks Hub and Cube internal to the compose network. The app reaches
them through `http://hub:8080` and `http://cube:4000`.
The bundled Docker stack keeps Formbricks Hub and Cube internal to the compose network. The app reaches them
through `http://hub:8080` and `http://cube:4000`.
</Note>
<Info>
+4 -3
View File
@@ -116,7 +116,8 @@ Cube is part of the baseline Formbricks v5 stack and is bundled with the chart b
- set `cube.enabled: false` to skip the bundled Cube deployment
- point the app at your external endpoint via `deployment.env.CUBEJS_API_URL`
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom`
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom` if you disable generated
secrets
## 4. Upgrade The Deployment
@@ -134,8 +135,8 @@ For a Formbricks 4.x to 5.0 migration, confirm the following before running the
- `HUB_API_KEY` is present
- your edge rate-limiting plan is in place
- any required `AI_*` variables are added
- `CUBEJS_API_SECRET` is configured (Cube is bundled by default; provide an external endpoint if you set
`cube.enabled: false`)
- `CUBEJS_API_SECRET` is configured (the generated app secret supplies it by default; provide an external
endpoint if you set `cube.enabled: false`)
## 5. Key Values
+6 -13
View File
@@ -1,14 +1,13 @@
import { z } from "zod";
import { ZActionClass } from "./action-classes";
import { ZId } from "./common";
import { ZJsWorkspaceStateSegment } from "./segment";
import { ZUploadFileConfig } from "./storage";
import { ZSurveyBase, surveyRefinement } from "./surveys/types";
import { ZWorkspace } from "./workspace";
export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
id: true,
// name intentionally omitted — internal label, not needed by SDK
name: true,
welcomeCard: true,
questions: true,
blocks: true,
@@ -20,7 +19,7 @@ export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
autoClose: true,
styling: true,
status: true,
// segment intentionally omitted from pick — replaced with minimal shape below
segment: true,
recontactDays: true,
displayLimit: true,
displayOption: true,
@@ -32,16 +31,9 @@ export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
recaptcha: true,
})
.extend({
// Only expose what the SDK needs: segment ID for membership check + whether any filters exist.
// Full filter logic (titles, descriptions, conditions) is evaluated server-side and must not
// be sent to the browser to avoid leaking sensitive targeting data.
segment: ZJsWorkspaceStateSegment.nullable(),
})
.superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
});
}).superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
});
export type TJsWorkspaceStateSurvey = z.infer<typeof ZJsWorkspaceStateSurvey>;
@@ -56,6 +48,7 @@ export const ZJsWorkspaceStateActionClass = ZActionClass.pick({
export type TJsWorkspaceStateActionClass = z.infer<typeof ZJsWorkspaceStateActionClass>;
export const ZJsWorkspaceStateWorkspaceSetting = ZWorkspace.pick({
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,