Compare commits

..

2 Commits

Author SHA1 Message Date
Bhagya Amarasinghe a7f83c42a4 fix: harden scoped storage paths 2026-05-19 12:06:26 +05:30
Bhagya Amarasinghe 9e1a8e96c8 fix: bind storage uploads to survey questions 2026-05-19 00:22:25 +05:30
124 changed files with 1397 additions and 2621 deletions
+5 -87
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,87 +156,6 @@ 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
@@ -246,7 +165,6 @@ 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,25 +70,6 @@ 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/workspace/general`,
href: `/workspaces/${workspace.id}/settings`,
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 url={`/workspaces/${workspace.id}/surveys`} />
<GoBackButton />
</div>
{/* Settings sidebar content */}
@@ -335,7 +335,6 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -374,14 +373,12 @@ 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,11 +1,4 @@
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);
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
return <>{props.children}</>;
};
@@ -1,54 +0,0 @@
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);
});
});
@@ -1,12 +0,0 @@
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,11 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
export default APIKeysPage;
@@ -1,18 +1,3 @@
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";
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;
export default PricingPage;
@@ -1,7 +1,6 @@
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";
@@ -13,9 +12,8 @@ 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: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -1,10 +1,9 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { notFound } 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";
@@ -12,19 +11,15 @@ 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: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { 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,11 +1 @@
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;
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
@@ -1,4 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -27,9 +26,8 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { 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,11 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
export default TeamsPage;
@@ -1,9 +1,7 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -11,7 +11,6 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -26,7 +25,6 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
@@ -105,8 +103,6 @@ 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}/settings/workspace`;
const basePath = `/workspaces/${workspaceId}`;
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");
@@ -103,7 +103,6 @@ describe("getWorkspaceStateData", () => {
id: workspaceId,
appSetupCompleted: true,
workspaceSettings: {
id: workspaceId,
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -112,14 +111,7 @@ describe("getWorkspaceStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
// `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",
},
],
surveys: mockWorkspaceData.surveys,
actionClasses: mockWorkspaceData.actionClasses,
});
@@ -219,7 +211,6 @@ describe("getWorkspaceStateData", () => {
const result = await getWorkspaceStateData(workspaceId);
expect(result.workspace.workspaceSettings).toEqual({
id: workspaceId,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
@@ -42,7 +42,6 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
where: { id: workspaceId },
select: {
id: true,
legacyEnvironmentId: true,
appSetupCompleted: true,
recontactDays: true,
clickOutsideClose: true,
@@ -73,9 +72,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
select: {
id: true,
welcomeCard: true,
// `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.
// name intentionally omitted — internal label not needed by the SDK
questions: true,
blocks: true,
variables: true,
@@ -102,9 +99,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: true,
status: true,
recaptcha: true,
// 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.
// 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.
segment: {
select: {
id: true,
@@ -138,46 +135,17 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
throw new ResourceNotFoundError("workspace", workspaceId);
}
// 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" },
},
};
// 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.
const transformedSurveys = workspaceData.surveys.map((survey) => {
const realHasFilters =
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
const sanitizedSegment = survey.segment
const minimalSegment = survey.segment
? {
id: survey.segment.id,
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,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
}
: null;
@@ -187,11 +155,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
segment: null,
});
return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
segment: sanitizedSegment,
};
return { ...transformed, segment: minimalSegment };
});
return {
@@ -199,7 +163,6 @@ 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,
@@ -208,11 +171,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: resolveStorageUrlsInObject(workspaceData.styling),
},
},
// 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[],
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: workspaceData.actionClasses,
};
} catch (error) {
@@ -9,10 +9,9 @@ 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(),
validateClientFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
}));
@@ -35,10 +34,6 @@ 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,
@@ -49,7 +44,7 @@ vi.mock("@/modules/api/v2/lib/element", () => ({
}));
vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mocks.validateFileUploads,
validateClientFileUploads: mocks.validateClientFileUploads,
}));
vi.mock("./response", () => ({
@@ -128,9 +123,8 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateClientFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
});
@@ -245,34 +239,6 @@ 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(),
@@ -312,7 +278,7 @@ describe("putResponseHandler", () => {
});
test("rejects invalid file upload updates", async () => {
mocks.validateFileUploads.mockReturnValue(false);
mocks.validateClientFileUploads.mockReturnValue(false);
const result = await putResponseHandler(createHandlerParams());
@@ -8,11 +8,10 @@ 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";
import { validateFileUploads } from "@/modules/storage/utils";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./response";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
@@ -127,7 +126,8 @@ const getSurveyForResponse = async (
const validateUpdateRequest = (
existingResponse: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
responseUpdateInput: TResponseUpdateInput,
workspaceId: string
): TRouteResult | undefined => {
if (existingResponse.finished) {
return {
@@ -135,7 +135,15 @@ const validateUpdateRequest = (
};
}
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
if (
!validateClientFileUploads({
data: responseUpdateInput.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
@@ -210,7 +218,7 @@ export const putResponseHandler = async ({
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { workspaceId: workspaceIdParam, responseId } = params;
const { workspaceId, responseId } = params;
if (!responseId) {
return {
@@ -218,14 +226,6 @@ 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;
@@ -250,7 +250,7 @@ export const putResponseHandler = async ({
};
}
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId);
if (validationResult) {
return validationResult;
}
@@ -104,11 +104,7 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -17,7 +17,7 @@ import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
@@ -147,7 +147,15 @@ export const POST = withV1ApiWrapper({
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
if (
!validateClientFileUploads({
data: responseInputData.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
@@ -66,7 +66,7 @@ export const POST = withV1ApiWrapper({
};
}
const { fileName, fileType, surveyId } = parsedInputResult.data;
const { fileName, fileType, surveyId, questionId } = parsedInputResult.data;
const [survey, organizationId] = await Promise.all([
getSurvey(surveyId),
@@ -109,6 +109,7 @@ export const POST = withV1ApiWrapper({
const fileUploadPermission = validateSurveyAllowsFileUpload({
fileName,
questionId,
blocks: survey.blocks,
questions: survey.questions,
});
@@ -118,7 +119,9 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse(
fileUploadPermission.reason === "no_file_upload_question"
? "Survey does not allow file uploads"
: "File extension is not allowed for this survey",
: fileUploadPermission.reason === "file_upload_question_not_found"
? "Question does not allow file uploads"
: "File extension is not allowed for this question",
undefined
),
};
@@ -134,7 +137,8 @@ export const POST = withV1ApiWrapper({
workspaceId,
fileType,
"private",
maxFileUploadSize
maxFileUploadSize,
["surveys", surveyId, "questions", questionId]
);
if (!signedUrlResponse.ok) {
@@ -51,7 +51,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const basePath = `/workspaces/${workspaceId}`;
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}/settings/workspace`;
const basePath = `/workspaces/${workspaceId}`;
if (code && typeof code !== "string") {
return {
@@ -37,7 +37,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const basePath = `/workspaces/${workspaceId}`;
if (code && typeof code !== "string") {
return {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExistInWorkspace } from "./contact";
import { doesContactExist } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
@@ -21,25 +21,24 @@ vi.mock("react", async () => {
});
const contactId = "test-contact-id";
const workspaceId = "test-workspace-id";
describe("doesContactExistInWorkspace", () => {
describe("doesContactExist", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return true if contact exists in the workspace", async () => {
test("should return true if contact exists", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
const result = await doesContactExist(contactId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId, workspaceId },
where: { id: contactId },
select: { id: true },
});
});
@@ -47,11 +46,11 @@ describe("doesContactExistInWorkspace", () => {
test("should return false if contact does not exist in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
const result = await doesContactExist(contactId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId, workspaceId },
where: { id: contactId },
select: { id: true },
});
});
@@ -1,18 +1,15 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const doesContactExistInWorkspace = reactCache(
async (id: string, workspaceId: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
workspaceId,
},
select: {
id: true,
},
});
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
},
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 { doesContactExistInWorkspace } from "./contact";
import { doesContactExist } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
@@ -30,7 +30,7 @@ vi.mock("@formbricks/database", () => ({
}));
vi.mock("./contact", () => ({
doesContactExistInWorkspace: vi.fn(),
doesContactExist: vi.fn(),
}));
const workspaceId = "workspace-id-mock";
@@ -81,13 +81,13 @@ describe("createDisplay", () => {
});
test("should create a display with contactId successfully", async () => {
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
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(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(doesContactExist).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(doesContactExistInWorkspace).mockResolvedValue(false);
vi.mocked(doesContactExist).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(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -139,16 +139,16 @@ describe("createDisplay", () => {
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(doesContactExist).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
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(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(doesContactExist).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(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(doesContactExist).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(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
});
test("should throw original error if doesContactExistInWorkspace fails", async () => {
test("should throw original error if doesContactExist fails", async () => {
const contactCheckError = new Error("Failed to check contact");
vi.mocked(doesContactExistInWorkspace).mockRejectedValue(contactCheckError);
vi.mocked(doesContactExist).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 { doesContactExistInWorkspace } from "./contact";
import { doesContactExist } 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 doesContactExistInWorkspace(contactId, workspaceId) : false;
const contactExists = contactId ? await doesContactExist(contactId) : false;
const survey = await prisma.survey.findUnique({
where: {
@@ -49,7 +49,18 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
return {
survey: {
@@ -73,6 +84,8 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
@@ -15,6 +15,7 @@ import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
@@ -89,6 +90,18 @@ const validateResponseSubmission = async (
return surveyCheckResult;
}
if (
!validateClientFileUploads({
data: responseInputData.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks),
@@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger";
import { ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth";
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
@@ -15,10 +15,14 @@ import { logFileDeletion } from "./lib/audit-logs";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZDownloadFileRequest.safeParse(params);
const fileName = params.filePath.join("/");
const paramValidation = ZDownloadFileRequest.safeParse({
accessType: params.accessType,
fileName,
});
if (!paramValidation.success) {
return responses.badRequestResponse(
@@ -28,7 +32,7 @@ export const GET = async (
);
}
const { accessType, fileName } = paramValidation.data;
const { accessType } = paramValidation.data;
const idParam = params.workspaceId;
// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
@@ -72,10 +76,14 @@ export const GET = async (
export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZDeleteFileRequest.safeParse(params);
const fileName = params.filePath.join("/");
const paramValidation = ZDeleteFileRequest.safeParse({
accessType: params.accessType,
fileName,
});
if (!paramValidation.success) {
const errorDetails = transformErrorToDetails(paramValidation.error);
@@ -88,7 +96,7 @@ export const DELETE = async (
return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true);
}
const { accessType, fileName } = paramValidation.data;
const { accessType } = paramValidation.data;
const idParam = params.workspaceId;
// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
@@ -140,6 +148,20 @@ export const DELETE = async (
);
if (!deleteResult.ok) {
if (!("error" in deleteResult)) {
logger.error({ deleteResult }, "Unknown delete failure result shape");
await logFileDeletion({
failureReason: "unknown_delete_failure",
accessType,
userId: session?.user?.id,
workspaceId: resolved.workspaceId,
apiUrl: request.url,
});
return responses.internalServerErrorResponse("Failed to delete file", true);
}
const { error } = deleteResult;
logger.error({ error }, "Error deleting file");
+5 -11
View File
@@ -1602,15 +1602,13 @@ checksums:
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
workspace/analysis/charts/ai_enable_in_settings: 426cb4525381e193e6c4dcce286e60c8
workspace/analysis/charts/ai_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
workspace/analysis/charts/ai_not_available: 173abfcd32dd45edcc258dfdaaed494b
workspace/analysis/charts/ai_not_enabled: 2066fe71ecf8994ba738c79b63a1934b
workspace/analysis/charts/ai_not_in_plan: 4b75e143c97d657bd91f857ff2bbf33f
workspace/analysis/charts/ai_not_enabled: 8651fdac58cd311d17a48001a880318d
workspace/analysis/charts/ai_not_in_plan: 60bb0792a1ed98c07d8694029cdfdb43
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
workspace/analysis/charts/ai_upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
@@ -2486,19 +2484,16 @@ checksums:
workspace/settings/feedback_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_directories/no_access: 707627df25fbaa28f18aa0f0d03dcb81
workspace/settings/feedback_directories/no_connectors: ccc725ff9a82a7b8ab68de735490a9b9
workspace/settings/feedback_directories/no_unassigned_workspaces_description: c96a260b582e6c930de72e6e69f9a9a6
workspace/settings/feedback_directories/no_unassigned_workspaces_title: 458d4289d73d799561bec26a0bb1a1a3
workspace/settings/feedback_directories/pause_connectors_confirmation_description: 0e30f827576b931651b9eae44e00279b
workspace/settings/feedback_directories/pause_connectors_confirmation_title: da1950dbb9ce62caa65c87ae8b88b1a1
workspace/settings/feedback_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
workspace/settings/feedback_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
workspace/settings/feedback_directories/title: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
workspace/settings/feedback_directories/unarchive_workspace_conflict: ed44bc0bd570b40de5251d04abf7bd08
workspace/settings/feedback_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
workspace/settings/feedback_directories/upgrade_prompt_description: eb8a4bf60bcae458899e1ea94094789d
workspace/settings/feedback_directories/upgrade_prompt_title: 0a7b67ccf15a0aa8c64e5da7feb6e532
workspace/settings/feedback_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
workspace/settings/feedback_directories/workspace_assigned_to_directory: 6b907668667a9c74a99c437fa3cc2046
workspace/settings/feedback_directories/workspaces_already_linked: ef6248289707611a44950c3406aec0ec
workspace/settings/feedback_directories/workspaces_being_added: e01628710aff05c5172f2f43aab1f6fb
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
@@ -2602,8 +2597,8 @@ checksums:
workspace/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
workspace/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
workspace/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
workspace/settings/profile/sso_identity_confirmation_failed: 2d699f31f3e92bca9508a2772b071a1f
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: 9a5d190ed96e0149ed431c130c40284d
workspace/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
workspace/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
workspace/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
workspace/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -3520,7 +3515,6 @@ checksums:
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
workspace/unify/api_ingestion_setup_description: d18a267d0e50198682950f5341307fa3
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
workspace/unify/clear_mapping: 9bd7c716667838b9f203f5af0ac2d651
-44
View File
@@ -4,7 +4,6 @@ import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
} from "./service";
@@ -208,47 +207,4 @@ 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,15 +59,6 @@ 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"
+10 -6
View File
@@ -22,7 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { parseStorageFileUrl, resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromWorkspaceId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -589,14 +589,18 @@ const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey:
const deletionPromises = fileUrls.map(async (fileUrl) => {
try {
const { pathname } = new URL(fileUrl);
const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean);
const storageFile = parseStorageFileUrl(fileUrl);
if (!storageId || !accessType || !fileName) {
throw new Error(`Invalid file path: ${pathname}`);
if (!storageFile) {
throw new Error(`Invalid storage file URL: ${fileUrl}`);
}
return deleteFile(storageId, accessType as "private" | "public", fileName, survey.workspaceId);
return deleteFile(
storageFile.storageId,
storageFile.accessType,
storageFile.fileName,
survey.workspaceId
);
} catch (error) {
logger.error(error, `Failed to delete file ${fileUrl}`);
}
@@ -38,50 +38,6 @@ 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", () => {
@@ -104,54 +60,4 @@ 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");
});
});
+2 -26
View File
@@ -2,30 +2,11 @@ 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.map((name) => ({
label: sanitizeFormulaInjection(name),
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
})),
fields,
});
try {
@@ -42,13 +23,8 @@ 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.aoa_to_sheet([headerRow, ...dataRows]);
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};
+3 -9
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Filter hinzufügen",
"add_to_dashboard": "Zum Dashboard hinzufügen",
"advanced_chart_builder_config_prompt": "Konfiguriere dein Diagramm und klicke auf \"Abfrage ausführen\", um eine Vorschau zu sehen",
"ai_enable_in_settings": "Aktiviere es in den Organisationseinstellungen.",
"ai_instance_not_configured": "KI ist auf dieser Instanz nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_not_available": "KI-Datenanalyse ist nicht verfügbar.",
"ai_not_enabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_not_in_plan": "KI-Datenanalyse ist in deinem aktuellen Plan nicht verfügbar.",
"ai_not_enabled": "KI-Datenanalyse ist für diese Organisation deaktiviert. Aktiviere sie in den Organisationseinstellungen.",
"ai_not_in_plan": "KI-Datenanalyse ist in deinem aktuellen Tarif nicht verfügbar. Führe ein Upgrade durch, um diese Funktion freizuschalten.",
"ai_query_placeholder": "z.B. Wie viele Nutzer haben sich letzte Woche angemeldet?",
"ai_query_section_description": "Beschreibe, was du sehen möchtest, und lass die KI das Diagramm erstellen.",
"ai_query_section_title": "Frag deine Daten",
"ai_upgrade_plan": "Plan upgraden",
"already_on_dashboard": "Bereits im Dashboard",
"and_filter_logic": "UND",
"apply_changes": "Änderungen übernehmen",
@@ -2593,8 +2591,6 @@
"nav_label": "Feedback-Verzeichnisse",
"no_access": "Du hast keine Berechtigung, Feedback-Verzeichnisse zu verwalten.",
"no_connectors": "Noch keine Feedback-Quellen mit diesem Verzeichnis verknüpft.",
"no_unassigned_workspaces_description": "Jeder Workspace ist bereits mit einem aktiven Feedback-Verzeichnis verknüpft. Entferne einen Workspace aus seinem aktuellen Verzeichnis, bevor du ihn hier zuweist.",
"no_unassigned_workspaces_title": "Keine nicht zugewiesenen Workspaces verfügbar",
"pause_connectors_confirmation_description": "Wenn du diese Feedback-Quellen pausierst, werden keine neuen Einträge mehr hinzugefügt.",
"pause_connectors_confirmation_title": "Verknüpfte Feedback-Quellen pausieren?",
"select_workspaces_placeholder": "Workspaces auswählen...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organisiere Feedback-Datensätze in Verzeichnissen und leite Daten zum richtigen Workspace weiter. Verfügbar in den Pro- und Scale-Plänen.",
"upgrade_prompt_title": "Upgrade durchführen, um Feedback-Datensatz-Verzeichnisse freizuschalten",
"workspace_access": "Workspace-Zugriff",
"workspace_assigned_to_directory": "{workspaceName} ist mit {directoryName} verknüpft",
"workspaces_already_linked": "Bereits verknüpfte Workspaces",
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
},
@@ -2716,7 +2711,7 @@
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authenticator-App.",
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie die Zwei-Faktor-Authentifizierung (2FA).",
"sso_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von Löschen dich zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
"two_factor_authentication_description": "Füge deinem Konto eine zusätzliche Sicherheitsebene hinzu, falls dein Passwort gestohlen wird.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authenticator-App ein.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Zulässige Werte: {values}",
"api_ingestion": "API-Erfassung",
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
"api_ingestion_setup_description": "Nutze die REST API, um Feedback-Datensätze direkt an Formbricks zu senden. Die API-Ingestion-Docs enthalten den Endpunkt, die Payload-Struktur und Authentifizierungsdetails.",
"auto_generated": "Automatisch generiert",
"change_file": "Datei ändern",
"clear_mapping": "Zuordnung löschen",
+3 -9
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Add filter",
"add_to_dashboard": "Add to Dashboard",
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
"ai_enable_in_settings": "Enable it in organization settings.",
"ai_instance_not_configured": "AI is not configured on this instance. Contact your administrator.",
"ai_not_available": "AI data analysis is not available.",
"ai_not_enabled": "AI data analysis is disabled for this organization.",
"ai_not_in_plan": "AI data analysis is not available on your current plan.",
"ai_not_enabled": "AI data analysis is disabled for this organization. Enable it in organization settings.",
"ai_not_in_plan": "AI data analysis is not available on your current plan. Upgrade to unlock this feature.",
"ai_query_placeholder": "e.g. How many users signed up last week?",
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
"ai_query_section_title": "Ask your data",
"ai_upgrade_plan": "Upgrade plan",
"already_on_dashboard": "Already on dashboard",
"and_filter_logic": "AND",
"apply_changes": "Apply Changes",
@@ -2593,19 +2591,16 @@
"nav_label": "Feedback Directories",
"no_access": "You do not have permission to manage feedback directories.",
"no_connectors": "No feedback sources linked to this directory yet.",
"no_unassigned_workspaces_description": "Every workspace is already linked to an active feedback directory. Remove a workspace from its current directory before assigning it here.",
"no_unassigned_workspaces_title": "No unassigned workspaces available",
"pause_connectors_confirmation_description": "Pausing these feedback sources will stop new records from being added.",
"pause_connectors_confirmation_title": "Pause linked feedback sources?",
"select_workspaces_placeholder": "Select workspaces...",
"show_archived": "Show archived",
"title": "Feedback Directories",
"unarchive": "Unarchive",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces already belong to another active feedback directory.",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
"upgrade_prompt_description": "Organize feedback records into directories and route data to the right workspace. Available on the Pro and Scale plans.",
"upgrade_prompt_title": "Upgrade to unlock Feedback Directories",
"workspace_access": "Workspace access",
"workspace_assigned_to_directory": "{workspaceName} is linked to {directoryName}",
"workspaces_already_linked": "Already linked workspaces",
"workspaces_being_added": "Workspaces being granted access"
},
@@ -3680,7 +3675,6 @@
"allowed_values": "Allowed values: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "Use the REST API to send feedback records directly into Formbricks. The API ingestion docs include the endpoint, payload shape, and authentication details.",
"auto_generated": "Auto-generated",
"change_file": "Change file",
"clear_mapping": "Clear mapping",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Añadir filtro",
"add_to_dashboard": "Añadir al panel de control",
"advanced_chart_builder_config_prompt": "Configura tu gráfico y haz clic en \"Ejecutar consulta\" para previsualizar",
"ai_enable_in_settings": "Actívalo en la configuración de la organización.",
"ai_instance_not_configured": "La IA no está configurada en esta instancia. Contacta con tu administrador.",
"ai_not_available": "El análisis de datos con IA no está disponible.",
"ai_not_enabled": "El análisis de datos con IA está desactivado para esta organización.",
"ai_not_in_plan": "El análisis de datos con IA no está disponible en tu plan actual.",
"ai_not_enabled": "El análisis de datos con IA está desactivado para esta organización. Actívalo en la configuración de la organización.",
"ai_not_in_plan": "El análisis de datos con IA no está disponible en tu plan actual. Actualiza para desbloquear esta función.",
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
"ai_query_section_title": "Pregunta a tus datos",
"ai_upgrade_plan": "Mejorar plan",
"already_on_dashboard": "Ya está en el panel",
"and_filter_logic": "Y",
"apply_changes": "Aplicar cambios",
@@ -2593,8 +2591,6 @@
"nav_label": "Directorios de Feedback",
"no_access": "No tienes permiso para gestionar directorios de feedback.",
"no_connectors": "Aún no hay fuentes de comentarios vinculadas a este directorio.",
"no_unassigned_workspaces_description": "Cada espacio de trabajo ya está vinculado a un directorio de feedback activo. Elimina un espacio de trabajo de su directorio actual antes de asignarlo aquí.",
"no_unassigned_workspaces_title": "No hay espacios de trabajo sin asignar disponibles",
"pause_connectors_confirmation_description": "Pausar estas fuentes de comentarios detendrá la adición de nuevos registros.",
"pause_connectors_confirmation_title": "¿Pausar las fuentes de comentarios vinculadas?",
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organiza los registros de feedback en directorios y dirige los datos al espacio de trabajo adecuado. Disponible en los planes Pro y Scale.",
"upgrade_prompt_title": "Mejora tu plan para desbloquear los Directorios de Registros de Feedback",
"workspace_access": "Acceso al espacio de trabajo",
"workspace_assigned_to_directory": "{workspaceName} está vinculado a {directoryName}",
"workspaces_already_linked": "Espacios de trabajo ya vinculados",
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
"sso_identity_confirmation_failed": "La confirmación de identidad SSO ha fallado. Por favor, intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para cuentas con SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continúa automáticamente.",
"sso_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará automáticamente.",
"two_factor_authentication": "Autenticación de dos factores",
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingesta de API",
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
"api_ingestion_setup_description": "Utiliza la API REST para enviar registros de feedback directamente a Formbricks. La documentación de ingesta de API incluye el endpoint, la estructura del payload y los detalles de autenticación.",
"auto_generated": "Generado automáticamente",
"change_file": "Cambiar archivo",
"clear_mapping": "Borrar asignación",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Ajouter un filtre",
"add_to_dashboard": "Ajouter au tableau de bord",
"advanced_chart_builder_config_prompt": "Configurez votre graphique et cliquez sur « Exécuter la requête » pour prévisualiser",
"ai_enable_in_settings": "Activez-la dans les paramètres de l'organisation.",
"ai_instance_not_configured": "L'IA n'est pas configurée sur cette instance. Contacte ton administrateur.",
"ai_not_available": "L'analyse de données par IA n'est pas disponible.",
"ai_not_enabled": "L'analyse de données par IA est désactivée pour cette organisation.",
"ai_not_in_plan": "L'analyse de données par IA n'est pas disponible avec ton abonnement actuel.",
"ai_not_enabled": "L'analyse de données par IA est désactivée pour cette organisation. Active-la dans les paramètres de l'organisation.",
"ai_not_in_plan": "L'analyse de données par IA n'est pas disponible sur ton forfait actuel. Passe à un forfait supérieur pour débloquer cette fonctionnalité.",
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière?",
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
"ai_query_section_title": "Interrogez vos données",
"ai_upgrade_plan": "Mettre à niveau l'abonnement",
"already_on_dashboard": "Déjà sur le tableau de bord",
"and_filter_logic": "ET",
"apply_changes": "Appliquer les modifications",
@@ -2593,8 +2591,6 @@
"nav_label": "Répertoires de feedback",
"no_access": "Tu n'as pas la permission de gérer les répertoires de retours.",
"no_connectors": "Aucune source de retours liée à ce répertoire pour le moment.",
"no_unassigned_workspaces_description": "Chaque espace de travail est déjà lié à un répertoire de commentaires actif. Retirez un espace de travail de son répertoire actuel avant de l'assigner ici.",
"no_unassigned_workspaces_title": "Aucun espace de travail non assigné disponible",
"pause_connectors_confirmation_description": "Mettre en pause ces sources de retours empêchera l'ajout de nouveaux enregistrements.",
"pause_connectors_confirmation_title": "Mettre en pause les sources de retours liées ?",
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organisez les enregistrements de feedback dans des répertoires et dirigez les données vers le bon espace de travail. Disponible avec les forfaits Pro et Scale.",
"upgrade_prompt_title": "Passez à un forfait supérieur pour débloquer les Répertoires d'enregistrements de feedback",
"workspace_access": "Accès à lespace de travail",
"workspace_assigned_to_directory": "{workspaceName} est lié à {directoryName}",
"workspaces_already_linked": "Espaces de travail déjà liés",
"workspaces_being_added": "Espaces de travail en cours d'ajout"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
"sso_identity_confirmation_failed": "La confirmation de l'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut te rediriger vers ton fournisseur d'identité pour confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"sso_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"two_factor_authentication": "Authentification à deux facteurs",
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valeurs autorisées: {values}",
"api_ingestion": "Ingestion par API",
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
"api_ingestion_setup_description": "Utilise l'API REST pour envoyer directement les retours d'expérience dans Formbricks. La documentation sur l'ingestion API inclut le point de terminaison, la structure de la charge utile et les détails d'authentification.",
"auto_generated": "Généré automatiquement",
"change_file": "Changer de fichier",
"clear_mapping": "Effacer le mappage",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Szűrő hozzáadása",
"add_to_dashboard": "Hozzáadás a vezérlőpulthoz",
"advanced_chart_builder_config_prompt": "Állítsd be a diagramot, és kattints a \"Lekérdezés futtatása\" gombra az előnézethez",
"ai_enable_in_settings": "Engedélyezze a szervezeti beállításokban.",
"ai_instance_not_configured": "Az AI nincs konfigurálva ezen a példányon. Kérjük, lépjen kapcsolatba a rendszergazdával.",
"ai_not_available": "Az AI adatelemzés nem elérhető.",
"ai_not_enabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
"ai_not_in_plan": "Az AI adatelemzés nem érhető el az Ön jelenlegi csomagjában.",
"ai_not_enabled": "Az AI adatelemzés le van tiltva ezen szervezet számára. Kérjük, engedélyezze a szervezeti beállításokban.",
"ai_not_in_plan": "Az AI adatelemzés nem elérhető az Ön jelenlegi csomagjában. Kérjük, frissítsen magasabb csomagra ezen funkció feloldásához.",
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
"ai_query_section_title": "Kérdezd meg az adataidat",
"ai_upgrade_plan": "Csomag frissítése",
"already_on_dashboard": "Már a vezérlőpulton van",
"and_filter_logic": "ÉS",
"apply_changes": "Módosítások alkalmazása",
@@ -2593,8 +2591,6 @@
"nav_label": "Visszajelzési könyvtárak",
"no_access": "Önnek nincs jogosultsága a visszajelzési könyvtárak kezeléséhez.",
"no_connectors": "Még nincsenek visszajelzési források kapcsolva ehhez a könyvtárhoz.",
"no_unassigned_workspaces_description": "Minden munkaterület már hozzá van rendelve egy aktív visszajelzési könyvtárhoz. Távolítson el egy munkaterületet a jelenlegi könyvtárából, mielőtt ide rendelné.",
"no_unassigned_workspaces_title": "Nincsenek hozzá nem rendelt munkaterületek",
"pause_connectors_confirmation_description": "Ezen visszajelzési források szüneteltetése megállítja az új rekordok hozzáadását.",
"pause_connectors_confirmation_title": "Szünetelteti a kapcsolódó visszajelzési forrásokat?",
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Szervezze a visszajelzési rekordokat könyvtárakba, és irányítsa az adatokat a megfelelő munkaterületre. A Pro és Scale csomagokban érhető el.",
"upgrade_prompt_title": "Frissítsen a csomagon, hogy feloldja a Visszajelzési Rekord Könyvtárakat",
"workspace_access": "Munkaterület-hozzáférés",
"workspace_assigned_to_directory": "{workspaceName} hozzá van rendelve ehhez: {directoryName}",
"workspaces_already_linked": "Már kapcsolt munkaterületek",
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése sikertelen volt. Kérjük, próbálja meg újra törölni fiókt.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés gomb kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatóhoz a fiók megerősítése érdekében. Ha ugyanazt a fiókot erősíti meg, a törlés automatikusan folytatódik.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytatódik.",
"two_factor_authentication": "Kétfaktoros hitelesítés",
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Engedélyezett értékek: {values}",
"api_ingestion": "API betöltés",
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
"api_ingestion_setup_description": "Használja a REST API-t, hogy közvetlenül küldjön visszajelzési rekordokat a Formbricks rendszerbe. Az API-betöltési dokumentáció tartalmazza a végpontot, az adatszerkezetet és a hitelesítési részleteket.",
"auto_generated": "Automatikusan generált",
"change_file": "Fájl módosítása",
"clear_mapping": "Leképezés törlése",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "フィルターを追加",
"add_to_dashboard": "ダッシュボードに追加",
"advanced_chart_builder_config_prompt": "チャートを設定して「クエリを実行」をクリックしてプレビューを表示",
"ai_enable_in_settings": "組織設定で有効にしてください。",
"ai_instance_not_configured": "このインスタンスではAIが設定されていません。管理者にお問い合わせください。",
"ai_not_available": "AIデータ分析は利用できません。",
"ai_not_enabled": "この組織ではAIデータ分析が無効になっています。",
"ai_not_in_plan": "現在のプランではAIデータ分析をご利用いただけません。",
"ai_not_enabled": "この組織ではAIデータ分析が無効になっています。組織設定で有効にしてください。",
"ai_not_in_plan": "AIデータ分析は現在のプランではご利用いただけません。この機能を利用するにはアップグレードしてください。",
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
"ai_query_section_title": "データに質問する",
"ai_upgrade_plan": "プランをアップグレード",
"already_on_dashboard": "すでにダッシュボードに追加済み",
"and_filter_logic": "AND",
"apply_changes": "変更を適用",
@@ -2593,8 +2591,6 @@
"nav_label": "フィードバックディレクトリ",
"no_access": "フィードバックディレクトリを管理する権限がありません。",
"no_connectors": "このディレクトリにリンクされたフィードバックソースがまだありません。",
"no_unassigned_workspaces_description": "すべてのワークスペースは既にアクティブなフィードバックディレクトリにリンクされています。ここに割り当てる前に、現在のディレクトリからワークスペースを削除してください。",
"no_unassigned_workspaces_title": "未割り当てのワークスペースがありません",
"pause_connectors_confirmation_description": "これらのフィードバックソースを一時停止すると、新しいレコードの追加が停止されます。",
"pause_connectors_confirmation_title": "リンクされたフィードバックソースを一時停止しますか?",
"select_workspaces_placeholder": "ワークスペースを選択...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "フィードバックレコードをディレクトリで整理し、適切なワークスペースにデータを振り分けられます。ProプランおよびScaleプランでご利用いただけます。",
"upgrade_prompt_title": "アップグレードしてフィードバックレコードディレクトリを利用",
"workspace_access": "ワークスペースアクセス",
"workspace_assigned_to_directory": "{workspaceName}は{directoryName}にリンクされています",
"workspaces_already_linked": "既にリンクされているワークスペース",
"workspaces_being_added": "アクセス権が付与されるワークスペース"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
"sso_identity_confirmation_failed": "SSO本人確認に失敗しました。もう一度アカウントの削除をお試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされる場合があります。同じアカウントが確認されると、削除自動的に続行されます。",
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除をお試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされることがあります。同じアカウントが確認されると、削除自動的に続行されます。",
"two_factor_authentication": "二段階認証",
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
@@ -3680,7 +3675,6 @@
"allowed_values": "許可される値: {values}",
"api_ingestion": "API取り込み",
"api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。",
"api_ingestion_setup_description": "REST APIを使用して、フィードバックレコードをFormbricksに直接送信できます。APIインジェストのドキュメントには、エンドポイント、ペイロード形式、認証の詳細が含まれています。",
"auto_generated": "自動生成",
"change_file": "ファイルを変更",
"clear_mapping": "マッピングをクリア",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Filter toevoegen",
"add_to_dashboard": "Toevoegen aan dashboard",
"advanced_chart_builder_config_prompt": "Configureer je grafiek en klik op \"Query uitvoeren\" om een voorbeeld te zien",
"ai_enable_in_settings": "Schakel het in bij de organisatie-instellingen.",
"ai_instance_not_configured": "AI is niet geconfigureerd op deze instantie. Neem contact op met je beheerder.",
"ai_not_available": "AI-data-analyse is niet beschikbaar.",
"ai_not_enabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
"ai_not_in_plan": "AI-gegevensanalyse is niet beschikbaar in je huidige abonnement.",
"ai_not_enabled": "AI-data-analyse is uitgeschakeld voor deze organisatie. Schakel het in bij de organisatie-instellingen.",
"ai_not_in_plan": "AI-data-analyse is niet beschikbaar in je huidige abonnement. Upgrade om deze functie te ontgrendelen.",
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
"ai_query_section_title": "Vraag het aan je data",
"ai_upgrade_plan": "Abonnement upgraden",
"already_on_dashboard": "Al op dashboard",
"and_filter_logic": "EN",
"apply_changes": "Wijzigingen toepassen",
@@ -2593,8 +2591,6 @@
"nav_label": "Feedbackmappen",
"no_access": "Je hebt geen toestemming om feedbackmappen te beheren.",
"no_connectors": "Nog geen feedbackbronnen gekoppeld aan deze directory.",
"no_unassigned_workspaces_description": "Elke workspace is al gekoppeld aan een actieve feedbackdirectory. Verwijder een workspace uit de huidige directory voordat je deze hier toewijst.",
"no_unassigned_workspaces_title": "Geen niet-toegewezen workspaces beschikbaar",
"pause_connectors_confirmation_description": "Het pauzeren van deze feedbackbronnen stopt het toevoegen van nieuwe gegevens.",
"pause_connectors_confirmation_title": "Gekoppelde feedbackbronnen pauzeren?",
"select_workspaces_placeholder": "Selecteer werkruimtes...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organiseer feedbackrecords in mappen en routeer gegevens naar de juiste workspace. Beschikbaar op de Pro- en Scale-abonnementen.",
"upgrade_prompt_title": "Upgrade om Feedbackrecord Mappen te ontgrendelen",
"workspace_access": "Workspace-toegang",
"workspace_assigned_to_directory": "{workspaceName} is gekoppeld aan {directoryName}",
"workspaces_already_linked": "Reeds gekoppelde werkruimtes",
"workspaces_being_added": "Werkruimtes die toegang krijgen"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het zijn dat je bij het selecteren van Verwijderen wordt doorgestuurd naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat het verwijderen automatisch door.",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
"two_factor_authentication": "Tweefactorauthenticatie",
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Toegestane waarden: {values}",
"api_ingestion": "API-inname",
"api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.",
"api_ingestion_setup_description": "Gebruik de REST API om feedbackgegevens rechtstreeks naar Formbricks te sturen. De API-ingestiedocumentatie bevat het endpoint, de payload-structuur en authenticatiegegevens.",
"auto_generated": "Automatisch gegenereerd",
"change_file": "Bestand wijzigen",
"clear_mapping": "Mapping wissen",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configure seu gráfico e clique em \"Executar consulta\" para visualizar",
"ai_enable_in_settings": "Ative nas configurações da organização.",
"ai_instance_not_configured": "A IA não está configurada nesta instância. Entre em contato com seu administrador.",
"ai_not_available": "A análise de dados com IA não está disponível.",
"ai_not_enabled": "A análise de dados por IA está desativada para esta organização.",
"ai_not_in_plan": "A análise de dados por IA não está disponível no seu plano atual.",
"ai_not_enabled": "A análise de dados com IA está desabilitada para esta organização. Habilite nas configurações da organização.",
"ai_not_in_plan": "A análise de dados com IA não está disponível no seu plano atual. Faça upgrade para desbloquear este recurso.",
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
"ai_query_section_title": "Pergunte aos seus dados",
"ai_upgrade_plan": "Fazer upgrade do plano",
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
@@ -2593,8 +2591,6 @@
"nav_label": "Diretórios de Feedback",
"no_access": "Você não tem permissão para gerenciar diretórios de feedback.",
"no_connectors": "Nenhuma fonte de feedback vinculada a este diretório ainda.",
"no_unassigned_workspaces_description": "Todos os workspaces já estão vinculados a um diretório de feedback ativo. Remova um workspace do seu diretório atual antes de atribuí-lo aqui.",
"no_unassigned_workspaces_title": "Nenhum workspace não atribuído disponível",
"pause_connectors_confirmation_description": "Pausar essas fontes de feedback impedirá que novos registros sejam adicionados.",
"pause_connectors_confirmation_title": "Pausar fontes de feedback vinculadas?",
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organize registros de feedback em diretórios e direcione dados para o workspace certo. Disponível nos planos Pro e Scale.",
"upgrade_prompt_title": "Faça upgrade para desbloquear Diretórios de Registros de Feedback",
"workspace_access": "Acesso ao workspace",
"workspace_assigned_to_directory": "{workspaceName} está vinculado a {directoryName}",
"workspaces_already_linked": "Workspaces já vinculados",
"workspaces_being_added": "Workspaces recebendo acesso"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
"sso_identity_confirmation_failed": "Falha na confirmação de identidade SSO. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, ao selecionar Excluir você pode ser redirecionado para seu provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continua automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continua automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingestão de API",
"api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.",
"api_ingestion_setup_description": "Use a API REST para enviar registros de feedback diretamente para o Formbricks. A documentação de ingestão da API inclui o endpoint, a estrutura do payload e detalhes de autenticação.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar arquivo",
"clear_mapping": "Limpar mapeamento",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configura o teu gráfico e clica em \"Executar consulta\" para pré-visualizar",
"ai_enable_in_settings": "Ative nas definições da organização.",
"ai_instance_not_configured": "A IA não está configurada nesta instância. Contacta o teu administrador.",
"ai_not_available": "A análise de dados por IA não está disponível.",
"ai_not_enabled": "A análise de dados com IA está desativada para esta organização.",
"ai_not_in_plan": "A análise de dados com IA não está disponível no teu plano atual.",
"ai_not_enabled": "A análise de dados por IA está desativada para esta organização. Ativa-a nas definições da organização.",
"ai_not_in_plan": "A análise de dados por IA não está disponível no teu plano atual. Faz upgrade para desbloquear esta funcionalidade.",
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
"ai_query_section_title": "Pergunta aos teus dados",
"ai_upgrade_plan": "Atualizar plano",
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
@@ -2593,8 +2591,6 @@
"nav_label": "Diretórios de Feedback",
"no_access": "Não tens permissão para gerir diretórios de feedback.",
"no_connectors": "Ainda sem fontes de feedback associadas a este diretório.",
"no_unassigned_workspaces_description": "Todos os espaços de trabalho já estão associados a um diretório de feedback ativo. Remove um espaço de trabalho do seu diretório atual antes de o atribuíres aqui.",
"no_unassigned_workspaces_title": "Nenhum espaço de trabalho disponível sem atribuição",
"pause_connectors_confirmation_description": "Pausar estas fontes de feedback irá impedir a adição de novos registos.",
"pause_connectors_confirmation_title": "Pausar fontes de feedback associadas?",
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organiza os registos de feedback em diretórios e encaminha os dados para o workspace certo. Disponível nos planos Pro e Scale.",
"upgrade_prompt_title": "Faz upgrade para desbloquear Diretórios de Registos de Feedback",
"workspace_access": "Acesso ao workspace",
"workspace_assigned_to_directory": "{workspaceName} está associado a {directoryName}",
"workspaces_already_linked": "Workspaces já vinculados",
"workspaces_being_added": "Workspaces a receber acesso"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
"sso_identity_confirmation_failed": "A confirmação de identidade SSO falhou. Por favor, tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, ao selecionares Eliminar podes ser redirecionado para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continua automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continua automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingestão de API",
"api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.",
"api_ingestion_setup_description": "Usa a REST API para enviar registos de feedback diretamente para o Formbricks. A documentação da API de ingestão inclui o endpoint, a estrutura do payload e os detalhes de autenticação.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar ficheiro",
"clear_mapping": "Limpar mapeamento",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Adaugă filtru",
"add_to_dashboard": "Adaugă la Tablou de Bord",
"advanced_chart_builder_config_prompt": "Configurează graficul și apasă pe \"Rulează interogarea\" pentru previzualizare",
"ai_enable_in_settings": "Activează-l în setările organizației.",
"ai_instance_not_configured": "AI nu este configurat pe această instanță. Contactează administratorul.",
"ai_not_available": "Analiza datelor cu AI nu este disponibilă.",
"ai_not_enabled": "Analiza datelor cu AI este dezactivată pentru această organizație.",
"ai_not_in_plan": "Analiza datelor cu AI nu este disponibilă în planul tău curent.",
"ai_not_enabled": "Analiza datelor cu AI este dezactivată pentru această organizație. Activează-o în setările organizației.",
"ai_not_in_plan": "Analiza datelor cu AI nu este disponibilă în planul tău actual. Treci la un plan superior pentru a debloca această funcție.",
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
"ai_query_section_title": "Întreabă-ți datele",
"ai_upgrade_plan": "Actualizează planul",
"already_on_dashboard": "Deja pe tabloul de bord",
"and_filter_logic": "ȘI",
"apply_changes": "Aplică modificările",
@@ -2593,8 +2591,6 @@
"nav_label": "Directoare de feedback",
"no_access": "Nu ai permisiunea de a gestiona directoarele de feedback.",
"no_connectors": "Nicio sursă de feedback conectată la acest director încă.",
"no_unassigned_workspaces_description": "Fiecare spațiu de lucru este deja conectat la un director de feedback activ. Elimină un spațiu de lucru din directorul său actual înainte de a-l atribui aici.",
"no_unassigned_workspaces_title": "Niciun spațiu de lucru neatribuit disponibil",
"pause_connectors_confirmation_description": "Pauza acestor surse de feedback va opri adăugarea de noi înregistrări.",
"pause_connectors_confirmation_title": "Pui pe pauză sursele de feedback conectate?",
"select_workspaces_placeholder": "Selectează spații de lucru...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organizează înregistrările de feedback în directoare și direcționează datele către workspace-ul potrivit. Disponibile în planurile Pro și Scale.",
"upgrade_prompt_title": "Actualizează pentru a debloca Directoarele pentru Înregistrări de Feedback",
"workspace_access": "Acces la spațiul de lucru",
"workspace_assigned_to_directory": "{workspaceName} este conectat la {directoryName}",
"workspaces_already_linked": "Spații de lucru deja conectate",
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci ștergi contul din nou.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"two_factor_authentication": "Autentificare în doi pași",
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valori permise: {values}",
"api_ingestion": "Ingestie API",
"api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.",
"api_ingestion_setup_description": "Folosește REST API pentru a trimite înregistrări de feedback direct în Formbricks. Documentația de ingestie API include endpoint-ul, structura payload-ului și detaliile de autentificare.",
"auto_generated": "Generat automat",
"change_file": "Schimbă fișierul",
"clear_mapping": "Șterge maparea",
+5 -11
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Добавить фильтр",
"add_to_dashboard": "Добавить на панель",
"advanced_chart_builder_config_prompt": "Настрой график и нажми «Выполнить запрос», чтобы посмотреть предварительный просмотр",
"ai_enable_in_settings": "Включите эту функцию в настройках организации.",
"ai_instance_not_configured": "ИИ не настроен на этом экземпляре. Свяжитесь с администратором.",
"ai_not_available": "Анализ данных с помощью ИИ недоступен.",
"ai_not_enabled": "ИИ-анализ данных отключён для этой организации.",
"ai_not_in_plan": "ИИ-анализ данных недоступен в твоём текущем тарифе.",
"ai_not_enabled": "Анализ данных с помощью ИИ отключён для этой организации. Включите его в настройках организации.",
"ai_not_in_plan": "Анализ данных с помощью ИИ недоступен в вашем текущем тарифе. Обновите тариф, чтобы получить эту функцию.",
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
"ai_query_section_title": "Спроси свои данные",
"ai_upgrade_plan": "Обновить тариф",
"already_on_dashboard": "Уже на дашборде",
"and_filter_logic": "И",
"apply_changes": "Применить изменения",
@@ -2429,7 +2427,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": "в месяц",
@@ -2593,8 +2591,6 @@
"nav_label": "Каталоги отзывов",
"no_access": "У тебя нет прав для управления директориями обратной связи.",
"no_connectors": "К этому каталогу пока не привязаны источники отзывов.",
"no_unassigned_workspaces_description": "Каждое рабочее пространство уже связано с активным каталогом отзывов. Удалите рабочее пространство из текущего каталога, прежде чем назначить его сюда.",
"no_unassigned_workspaces_title": "Нет доступных неназначенных рабочих пространств",
"pause_connectors_confirmation_description": "Приостановка этих источников отзывов остановит добавление новых записей.",
"pause_connectors_confirmation_title": "Приостановить связанные источники отзывов?",
"select_workspaces_placeholder": "Выберите рабочие области...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Организуй записи обратной связи в директории и направляй данные в нужное рабочее пространство. Доступно в тарифах Pro и Scale.",
"upgrade_prompt_title": "Обнови тариф, чтобы получить доступ к директориям записей обратной связи",
"workspace_access": "Доступ к рабочему пространству",
"workspace_assigned_to_directory": "{workspaceName} связано с {directoryName}",
"workspaces_already_linked": "Уже связанные рабочие пространства",
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
"sso_identity_confirmation_failed": "Не удалось подтвердить SSO-идентификацию. Попробуйте удалить свой аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для SSO-аккаунтов при выборе «Удалить» может потребоваться переход к вашему провайдеру идентификации для подтверждения этого аккаунта. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для аккаунтов SSO при выборе «Удалить» вы можете быть перенаправлены к поставщику удостоверений, чтобы подтвердить этот аккаунт. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"two_factor_authentication": "Двухфакторная аутентификация",
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Допустимые значения: {values}",
"api_ingestion": "Импорт через API",
"api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.",
"api_ingestion_setup_description": "Используйте REST API для прямой отправки записей отзывов в Formbricks. Документация по API включает конечную точку, структуру данных и сведения об аутентификации.",
"auto_generated": "Автоматически генерируется",
"change_file": "Изменить файл",
"clear_mapping": "Очистить сопоставление",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Lägg till filter",
"add_to_dashboard": "Lägg till på instrumentpanelen",
"advanced_chart_builder_config_prompt": "Konfigurera ditt diagram och klicka på \"Kör fråga\" för att förhandsgranska",
"ai_enable_in_settings": "Aktivera det i organisationsinställningarna.",
"ai_instance_not_configured": "AI är inte konfigurerad på denna instans. Kontakta din administratör.",
"ai_not_available": "AI-dataanalys är inte tillgänglig.",
"ai_not_enabled": "AI-dataanalys är inaktiverad för den här organisationen.",
"ai_not_in_plan": "AI-dataanalys är inte tillgänglig i din nuvarande plan.",
"ai_not_enabled": "AI-dataanalys är inaktiverad för denna organisation. Aktivera det i organisationsinställningarna.",
"ai_not_in_plan": "AI-dataanalys är inte tillgänglig i din nuvarande plan. Uppgradera för att låsa upp denna funktion.",
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
"ai_query_section_title": "Fråga din data",
"ai_upgrade_plan": "Uppgradera plan",
"already_on_dashboard": "Redan på instrumentpanelen",
"and_filter_logic": "OCH",
"apply_changes": "Verkställ ändringar",
@@ -2593,8 +2591,6 @@
"nav_label": "Feedbackkataloger",
"no_access": "Du har inte behörighet att hantera feedback-kataloger.",
"no_connectors": "Inga feedbackkällor länkade till den här katalogen ännu.",
"no_unassigned_workspaces_description": "Varje arbetsyta är redan kopplad till en aktiv feedbackkatalog. Ta bort en arbetsyta från dess nuvarande katalog innan du tilldelar den här.",
"no_unassigned_workspaces_title": "Inga otilldelade arbetsytor tillgängliga",
"pause_connectors_confirmation_description": "Att pausa dessa feedbackkällor kommer att stoppa nya poster från att läggas till.",
"pause_connectors_confirmation_title": "Pausa länkade feedbackkällor?",
"select_workspaces_placeholder": "Välj arbetsytor...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organisera feedbackposter i kataloger och dirigera data till rätt arbetsyta. Tillgängligt på Pro- och Scale-planerna.",
"upgrade_prompt_title": "Uppgradera för att låsa upp Feedbackpostkataloger",
"workspace_access": "Arbetsyteåtkomst",
"workspace_assigned_to_directory": "{workspaceName} är kopplad till {directoryName}",
"workspaces_already_linked": "Redan länkade arbetsytor",
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelse misslyckades. Försök att radera ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan ett klick på Radera omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter raderingen automatiskt.",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen automatiskt.",
"two_factor_authentication": "Tvåfaktorsautentisering",
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Tillåtna värden: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "Använd REST API för att skicka feedbackposter direkt till Formbricks. API-dokumentationen innehåller endpoint, datastruktur och autentiseringsdetaljer.",
"auto_generated": "Automatiskt genererad",
"change_file": "Byt fil",
"clear_mapping": "Rensa mappning",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Filtre ekle",
"add_to_dashboard": "Panoya Ekle",
"advanced_chart_builder_config_prompt": "Grafiğini yapılandır ve önizleme için \"Sorguyu Çalıştır\"a tıkla",
"ai_enable_in_settings": "Organizasyon ayarlarından etkinleştirin.",
"ai_instance_not_configured": "Bu örnekte AI yapılandırılmamış. Yöneticinle iletişime geç.",
"ai_not_available": "AI veri analizi mevcut değil.",
"ai_not_enabled": "Bu kuruluş için AI veri analizi devre dışı bırakılmış.",
"ai_not_in_plan": "AI veri analizi mevcut planınızda mevcut değil.",
"ai_not_enabled": "Bu organizasyon için AI veri analizi devre dışı. Organizasyon ayarlarından etkinleştir.",
"ai_not_in_plan": "AI veri analizi mevcut planında bulunmuyor. Bu özelliğin kilidini açmak için yükselt.",
"ai_query_placeholder": "örn. Geçen hafta kaç kullanıcı kaydoldu?",
"ai_query_section_description": "Ne görmek istediğini anlat, AI grafiği oluştursun.",
"ai_query_section_title": "Verilerine sor",
"ai_upgrade_plan": "Planı yükselt",
"already_on_dashboard": "Zaten panoda",
"and_filter_logic": "VE",
"apply_changes": "Değişiklikleri Uygula",
@@ -2593,8 +2591,6 @@
"nav_label": "Geri Bildirim Dizinleri",
"no_access": "Geri bildirim dizinlerini yönetme yetkin yok.",
"no_connectors": "Bu dizine henüz bağlı geri bildirim kaynağı yok.",
"no_unassigned_workspaces_description": "Her çalışma alanı zaten aktif bir geri bildirim dizinine bağlı. Buraya atamadan önce bir çalışma alanını mevcut dizininden kaldırın.",
"no_unassigned_workspaces_title": "Atanmamış çalışma alanı yok",
"pause_connectors_confirmation_description": "Bu geri bildirim kaynaklarını duraklatmak, yeni kayıtların eklenmesini durdurur.",
"pause_connectors_confirmation_title": "Bağlı geri bildirim kaynakları duraklatılsın mı?",
"select_workspaces_placeholder": "Çalışma alanlarını seç...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Geri bildirim kayıtlarını dizinler halinde düzenleyin ve verileri doğru çalışma alanına yönlendirin. Pro ve Scale planlarında kullanılabilir.",
"upgrade_prompt_title": "Geri Bildirim Kayıt Dizinlerinin Kilidini Açmak İçin Yükseltin",
"workspace_access": "Çalışma alanı erişimi",
"workspace_assigned_to_directory": "{workspaceName}, {directoryName} dizinine bağlı",
"workspaces_already_linked": "Zaten bağlı çalışma alanları",
"workspaces_being_added": "Erişim verilen çalışma alanları"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedekleme kodlarını güvenli bir yerde sakla.",
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanla tara.",
"security_description": "Şifreni ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönet.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı tekrar silmeyi deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesapları için Sil seçeneğine tıkladığınızda, bu hesabı onaylamak için kimlik sağlayıcınıza yönlendirilebilirsiniz. Aynı hesap onaylanırsa, silme işlemi otomatik olarak devam eder.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek, bu hesabı onaylamanız için sizi kimlik sağlayıcınıza yönlendirebilir. Aynı hesap onaylanırsa silme işlemi otomatik olarak devam eder.",
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
"two_factor_authentication_description": "Şifren çalınması durumunda hesabına ekstra bir güvenlik katmanı ekle.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "İki faktörlü kimlik doğrulama etkinleştirildi. Lütfen kimlik doğrulayıcı uygulamandaki altı haneli kodu gir.",
@@ -3680,7 +3675,6 @@
"allowed_values": "İzin verilen değerler: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "Geri bildirim kayıtlarını doğrudan Formbricks'e göndermek için REST API'sini kullanın. API entegrasyon dokümanları, endpoint, payload yapısı ve kimlik doğrulama detaylarını içerir.",
"auto_generated": "Otomatik olarak oluşturuldu",
"change_file": "Dosyayı değiştir",
"clear_mapping": "Eşleştirmeyi temizle",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "添加过滤器",
"add_to_dashboard": "添加到 Dashboard",
"advanced_chart_builder_config_prompt": "配置你的图表,然后点击“运行查询”预览",
"ai_enable_in_settings": "在组织设置中启用。",
"ai_instance_not_configured": "此实例未配置 AI。请联系您的管理员。",
"ai_not_available": "AI 数据分析不可用。",
"ai_not_enabled": "此组织已禁用 AI 数据分析。",
"ai_not_in_plan": "您当前的套餐不包含 AI 数据分析功能。",
"ai_not_enabled": "此组织已禁用 AI 数据分析。请在组织设置中启用。",
"ai_not_in_plan": "您当前的套餐不包含 AI 数据分析。升级以解锁此功能。",
"ai_query_placeholder": "例如:上周有多少用户注册?",
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
"ai_query_section_title": "向你的数据提问",
"ai_upgrade_plan": "升级套餐",
"already_on_dashboard": "已在仪表板上",
"and_filter_logic": "且",
"apply_changes": "应用更改",
@@ -2593,8 +2591,6 @@
"nav_label": "反馈目录",
"no_access": "你没有管理反馈目录的权限。",
"no_connectors": "暂未关联反馈来源到此目录。",
"no_unassigned_workspaces_description": "每个工作区都已链接到活跃的反馈目录。在此处分配前,请先从当前目录中移除工作区。",
"no_unassigned_workspaces_title": "没有可用的未分配工作区",
"pause_connectors_confirmation_description": "暂停这些反馈来源后,将不会有新记录添加进来。",
"pause_connectors_confirmation_title": "暂停关联反馈来源?",
"select_workspaces_placeholder": "选择工作区...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "将反馈记录整理到目录中,并将数据路由到正确的工作空间。专业版和规模版方案可用。",
"upgrade_prompt_title": "升级以解锁反馈记录目录",
"workspace_access": "工作区访问权限",
"workspace_assigned_to_directory": "{workspaceName} 已链接到 {directoryName}",
"workspaces_already_linked": "已关联的工作区",
"workspaces_being_added": "将被授权访问的工作区"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请尝试再次删除的账户。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除自动继续。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除的账户。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除自动继续。",
"two_factor_authentication": "双因素 认证",
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -3680,7 +3675,6 @@
"allowed_values": "允许的值:{values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "使用 REST API 直接将反馈记录发送到 Formbricks。API 接入文档包含端点、请求体结构和身份验证详情。",
"auto_generated": "自动生成",
"change_file": "更换文件",
"clear_mapping": "清除映射",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "新增篩選器",
"add_to_dashboard": "新增到儀表板",
"advanced_chart_builder_config_prompt": "設定你的圖表,然後點擊「執行查詢」預覽",
"ai_enable_in_settings": "請在組織設定中啟用。",
"ai_instance_not_configured": "此執行個體未設定 AI。請聯絡您的管理員。",
"ai_not_available": "AI 資料分析無法使用。",
"ai_not_enabled": "此組織已停用 AI 資料分析功能。",
"ai_not_in_plan": "您目前的方案不包含 AI 資料分析功能。",
"ai_not_enabled": "此組織已停用 AI 資料分析。請在組織設定中啟用。",
"ai_not_in_plan": "您目前的方案不包含 AI 資料分析。請升級以解鎖此功能。",
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
"ai_query_section_title": "詢問你的數據",
"ai_upgrade_plan": "升級方案",
"already_on_dashboard": "已在儀表板上",
"and_filter_logic": "且",
"apply_changes": "套用變更",
@@ -2593,8 +2591,6 @@
"nav_label": "意見回饋目錄",
"no_access": "你沒有權限管理意見回饋目錄。",
"no_connectors": "此目錄尚未連結任何回饋來源。",
"no_unassigned_workspaces_description": "每個工作區都已連結到作用中的意見回饋目錄。請先從目前目錄中移除工作區,再於此處指派。",
"no_unassigned_workspaces_title": "沒有可用的未指派工作區",
"pause_connectors_confirmation_description": "暫停這些回饋來源將停止新增記錄。",
"pause_connectors_confirmation_title": "暫停已連結的回饋來源?",
"select_workspaces_placeholder": "選擇工作區...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "將回饋記錄整理至目錄中,並將資料導向正確的工作區。專業版和企業版方案提供此功能。",
"upgrade_prompt_title": "升級以解鎖回饋記錄目錄功能",
"workspace_access": "工作區存取權限",
"workspace_assigned_to_directory": "{workspaceName} 已連結到 {directoryName}",
"workspaces_already_linked": "已連結的工作區",
"workspaces_being_added": "正在授予存取權限的工作區"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對 SSO 帳,選擇刪除可能會將重新導向至身分提供者以確認此帳號。若確認的是同一帳號,刪除作業將自動繼續。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對 SSO 帳,選擇刪除可能會將重新導向至身分提供者以確認此帳戶。如果確認的是同一個帳戶,刪除會自動繼續。",
"two_factor_authentication": "雙重驗證",
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -3680,7 +3675,6 @@
"allowed_values": "允許的值:{values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "使用 REST API 直接將意見回饋記錄傳送至 Formbricks。API 擷取文件包含端點、負載格式及驗證詳情。",
"auto_generated": "自動生成",
"change_file": "更換檔案",
"clear_mapping": "清除對應",
@@ -26,7 +26,7 @@ export const fileUploadQuestion: Survey["questions"][number] = {
export const responseData: Response["data"] = {
[openTextQuestion.id]: "Open Text Answer",
[fileUploadQuestion.id]: [
`https://example.com/dummy/${workspaceId}/private/file1.png`,
`https://example.com/dummy/${workspaceId}/private/file2.pdf`,
`https://example.com/storage/${workspaceId}/private/file1.png`,
`https://example.com/storage/${workspaceId}/private/file2.pdf`,
],
};
@@ -4,6 +4,7 @@ import { Result, okVoid } from "@formbricks/types/error-handlers";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { deleteFile } from "@/modules/storage/service";
import { parseStorageFileUrl } from "@/modules/storage/utils";
export const findAndDeleteUploadedFilesInResponse = async (
responseData: Response["data"],
@@ -24,13 +25,12 @@ export const findAndDeleteUploadedFilesInResponse = async (
const deletionPromises = fileUrls.map(async (fileUrl) => {
try {
const { pathname } = new URL(fileUrl);
const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean);
const storageFile = parseStorageFileUrl(fileUrl);
if (!storageId || !accessType || !fileName) {
throw new Error(`Invalid file path: ${pathname}`);
if (!storageFile) {
throw new Error(`Invalid storage file URL: ${fileUrl}`);
}
return deleteFile(storageId, accessType as "private" | "public", fileName, workspaceId);
return deleteFile(storageFile.storageId, storageFile.accessType, storageFile.fileName, workspaceId);
} catch (error) {
logger.error({ error, fileUrl }, "Failed to delete file");
}
@@ -363,10 +363,8 @@ export const generateAIChartAction = authenticatedActionClient
await checkDashboardsEnabled(organizationId);
// 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");
// Verify AI is entitled, enabled at org level, and configured at instance level
await assertOrganizationAIConfigured(organizationId, "dataAnalysis");
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
@@ -7,22 +7,18 @@ import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
import {
type TAIUnavailableActionType,
type TAIUnavailableReason,
getAIUnavailableAction,
} from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface AIQuerySectionProps {
workspaceId: string;
onChartGenerated: (data: AnalyticsResponse) => void;
feedbackDirectoryId: string;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function AIQuerySection({
@@ -35,31 +31,7 @@ export function AIQuerySection({
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { t } = useTranslation();
const translateAIUnavailableMessage = (reason: TAIUnavailableReason | undefined): string => {
switch (reason) {
case "not_in_plan":
return t("workspace.analysis.charts.ai_not_in_plan");
case "not_enabled":
return t("workspace.analysis.charts.ai_not_enabled");
case "instance_not_configured":
return t("workspace.analysis.charts.ai_instance_not_configured");
default:
return t("workspace.analysis.charts.ai_not_available");
}
};
const translateAIUnavailableAction = (actionType: TAIUnavailableActionType): string => {
switch (actionType) {
case "enable_ai":
return t("workspace.analysis.charts.ai_enable_in_settings");
case "upgrade_plan":
return t("workspace.analysis.charts.ai_upgrade_plan");
}
};
const aiUnavailableMessage = translateAIUnavailableMessage(aiUnavailableReason);
const aiUnavailableAction = getAIUnavailableAction(aiUnavailableReason, workspaceId);
const showAIDataAnalysisDisabledAlert = !isAIAvailable && aiUnavailableReason === "not_enabled";
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -111,31 +83,56 @@ export function AIQuerySection({
maxLength={2000}
disabled={!isAIAvailable || isGenerating}
/>
<Button
type="submit"
variant="default"
className="w-full"
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
loading={isGenerating}>
<WandSparklesIcon className="h-4 w-4" />
{t("workspace.analysis.charts.create_chart_with_ai")}
</Button>
{!isAIAvailable && (
<Alert variant="info" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
<span>{aiUnavailableMessage}</span>
</AlertDescription>
{aiUnavailableAction && (
<AlertButton asChild>
<Link href={aiUnavailableAction.href}>
{translateAIUnavailableAction(aiUnavailableAction.type)}
</Link>
</AlertButton>
)}
</Alert>
{showAIDataAnalysisDisabledAlert ? (
<Button
type="submit"
variant="default"
className="w-full"
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
loading={isGenerating}>
<WandSparklesIcon className="h-4 w-4" />
{t("workspace.analysis.charts.create_chart_with_ai")}
</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
type="submit"
variant="default"
className="w-full"
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
loading={isGenerating}>
<WandSparklesIcon className="h-4 w-4" />
{t("workspace.analysis.charts.create_chart_with_ai")}
</Button>
</div>
</TooltipTrigger>
{!isAIAvailable && (
<TooltipContent>
{{
not_in_plan: t("workspace.analysis.charts.ai_not_in_plan"),
not_enabled: t("workspace.analysis.charts.ai_not_enabled"),
instance_not_configured: t("workspace.analysis.charts.ai_instance_not_configured"),
}[aiUnavailableReason ?? ""] ?? t("workspace.analysis.charts.ai_not_available")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</form>
</div>
{showAIDataAnalysisDisabledAlert && (
<Alert variant="info" size="small">
<span className="truncate">{t("workspace.surveys.edit.ai_data_analysis_disabled")}</span>
<Link
href={`/workspaces/${workspaceId}/settings/organization/general`}
className="ml-2 inline-flex shrink-0 underline">
Enable it in organization settings.
</Link>
</Alert>
)}
</div>
);
}
@@ -1,11 +1,10 @@
import { use } from "react";
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getAIDataAnalysisUnavailableReason, 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";
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
@@ -21,8 +20,6 @@ interface ChartsListContentProps {
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
isAIAvailable: boolean;
aiUnavailableReason?: TAIUnavailableReason;
}
const ChartsListContent = ({
@@ -30,20 +27,11 @@ const ChartsListContent = ({
workspaceId,
isReadOnly,
directories,
isAIAvailable,
aiUnavailableReason,
}: Readonly<ChartsListContentProps>) => {
const charts = use(chartsPromise);
return (
<ChartsList
charts={charts}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
isAIAvailable={isAIAvailable}
aiUnavailableReason={aiUnavailableReason}
/>
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
);
};
@@ -87,7 +75,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
getConnectorsWithMappings(workspaceId),
getOrganizationAIConfig(organization.id),
]);
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
const isAIAvailable = !aiUnavailableReason;
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
@@ -115,8 +103,6 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
isAIAvailable={isAIAvailable}
aiUnavailableReason={aiUnavailableReason}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
@@ -1,7 +1,6 @@
import { getTranslate } from "@/lingodotdev/server";
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartsListProps {
@@ -9,8 +8,6 @@ interface ChartsListProps {
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
isAIAvailable: boolean;
aiUnavailableReason?: TAIUnavailableReason;
}
export const ChartsList = async ({
@@ -18,8 +15,6 @@ export const ChartsList = async ({
workspaceId,
isReadOnly,
directories,
isAIAvailable,
aiUnavailableReason,
}: Readonly<ChartsListProps>) => {
const t = await getTranslate();
@@ -47,8 +42,6 @@ export const ChartsList = async ({
workspaceId={workspaceId}
directories={directories}
buttonProps={{ variant: "secondary" }}
isAIAvailable={isAIAvailable}
aiUnavailableReason={aiUnavailableReason}
/>
)}
</div>
@@ -4,7 +4,6 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
@@ -16,7 +15,7 @@ interface CreateChartButtonProps {
showIcon?: boolean;
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function CreateChartButton({
@@ -1,7 +1,6 @@
"use client";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
@@ -14,7 +13,7 @@ export interface CreateChartDialogProps {
onSuccess?: () => void;
directories: { id: string; name: string }[];
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function CreateChartDialog({
@@ -11,7 +11,6 @@ import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Alert } from "@/modules/ui/components/alert";
@@ -37,7 +36,7 @@ interface CreateChartViewProps {
onSuccess?: () => void;
directories: { id: string; name: string }[];
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function CreateChartView({
@@ -1,26 +0,0 @@
import { describe, expect, test } from "vitest";
import { getAIUnavailableAction } from "./ai-availability";
describe("ai availability helpers", () => {
test("returns the organization settings action when AI is not enabled", () => {
expect(getAIUnavailableAction("not_enabled", "workspace-1")).toEqual({
href: "/workspaces/workspace-1/settings/organization/general",
type: "enable_ai",
});
});
test("returns the billing action when AI is not in the plan", () => {
expect(getAIUnavailableAction("not_in_plan", "workspace-1")).toEqual({
href: "/workspaces/workspace-1/settings/organization/billing",
type: "upgrade_plan",
});
});
test("does not return an action when the instance is not configured", () => {
expect(getAIUnavailableAction("instance_not_configured", "workspace-1")).toBeUndefined();
});
test("does not return an action when the reason is unavailable", () => {
expect(getAIUnavailableAction(undefined, "workspace-1")).toBeUndefined();
});
});
@@ -1,28 +0,0 @@
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export type TAIUnavailableActionType = "enable_ai" | "upgrade_plan";
interface AIUnavailableAction {
href: string;
type: TAIUnavailableActionType;
}
export const getAIUnavailableAction = (
reason: TAIUnavailableReason | undefined,
workspaceId: string
): AIUnavailableAction | undefined => {
if (reason === "not_enabled") {
return {
href: `/workspaces/${workspaceId}/settings/organization/general`,
type: "enable_ai",
};
}
if (reason === "not_in_plan") {
return {
href: `/workspaces/${workspaceId}/settings/organization/billing`,
type: "upgrade_plan",
};
}
return undefined;
};
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -32,7 +31,7 @@ interface AddExistingChartsDialogProps {
existingChartIds: string[];
onSuccess: () => void;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
interface ChartOption {
@@ -6,7 +6,6 @@ import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
import { Button } from "@/modules/ui/components/button";
@@ -23,7 +22,7 @@ interface DashboardControlBarProps {
hasChanges: boolean;
isReadOnly: boolean;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
onRefresh: () => void;
onEditToggle: () => void;
onSave: () => void;
@@ -11,7 +11,6 @@ import "react-resizable/css/styles.css";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
@@ -41,7 +40,7 @@ interface DashboardDetailClientProps {
directories: { id: string; name: string }[];
isReadOnly: boolean;
isAIAvailable: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
const widgetsToLayout = (widgets: TDashboardWidget[]): LayoutItem[] => {
@@ -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 { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getAIDataAnalysisUnavailableReason, 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 = getAISmartToolsUnavailableReason(aiConfig);
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
const isAIAvailable = !aiUnavailableReason;
let dashboard;
@@ -1,5 +1,3 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -8,17 +6,6 @@ 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", () => {
@@ -107,20 +94,5 @@ 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,15 +436,17 @@ 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", {
plan: getCurrentCloudPlanLabel(pendingChange.targetPlan, t),
date: formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
}),
})}
{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",
})
)}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
@@ -15,7 +15,6 @@ import {
updateFeedbackDirectoryAction,
} from "@/modules/ee/feedback-directory/actions";
import { ArchiveFeedbackDirectory } from "@/modules/ee/feedback-directory/components/feedback-directory-settings/archive-feedback-directory";
import { getWorkspaceAccessConflictState } from "@/modules/ee/feedback-directory/lib/workspace-access-conflicts";
import {
TFeedbackDirectoryDetails,
TFeedbackDirectoryUpdateInput,
@@ -24,7 +23,6 @@ import {
getTranslatedFeedbackDirectoryError,
} from "@/modules/ee/feedback-directory/types/feedback-directory";
import { TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/workspace";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -98,20 +96,6 @@ export const FeedbackDirectorySettingsModal = ({
[orgWorkspaces, workspaceAccessMap, directory?.id]
);
const workspaceConflictInput = useMemo(
() => ({
orgWorkspaces,
workspaceAccessByWorkspace,
currentDirectoryId: directory?.id,
}),
[orgWorkspaces, workspaceAccessByWorkspace, directory?.id]
);
const workspaceConflictState = useMemo(
() => getWorkspaceAccessConflictState(workspaceConflictInput),
[workspaceConflictInput]
);
const initialWorkspaceIds = useMemo(
() => directory?.workspaces.map((workspace) => workspace.workspaceId) ?? [],
[directory?.workspaces]
@@ -133,7 +117,6 @@ export const FeedbackDirectorySettingsModal = ({
setValue,
reset,
} = form;
const selectedWorkspaceIds = form.watch("workspaceIds") ?? [];
const workspaceNameById = useMemo(() => {
const map = new Map(orgWorkspaces.map((workspace) => [workspace.id, workspace.name]));
@@ -307,7 +290,7 @@ export const FeedbackDirectorySettingsModal = ({
</Muted>
<MultiSelect
options={workspaceOptions}
value={selectedWorkspaceIds}
value={form.watch("workspaceIds") ?? []}
onChange={(selected) => {
setValue("workspaceIds", selected, { shouldDirty: true });
}}
@@ -315,30 +298,6 @@ export const FeedbackDirectorySettingsModal = ({
placeholder={t("workspace.settings.feedback_directories.select_workspaces_placeholder")}
containerClassName="focus-within:ring-0 focus-within:ring-offset-0"
/>
{workspaceConflictState.showBlockedExplanation && (
<Alert variant="info" className="items-start">
<div className="min-w-0 space-y-1">
<AlertTitle className="truncate">
{t("workspace.settings.feedback_directories.no_unassigned_workspaces_title")}
</AlertTitle>
<AlertDescription className="overflow-visible whitespace-normal">
<p>
{t("workspace.settings.feedback_directories.no_unassigned_workspaces_description")}
</p>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{workspaceConflictState.conflictDetails.map((conflict) => (
<li key={conflict.workspaceId}>
{t("workspace.settings.feedback_directories.workspace_assigned_to_directory", {
workspaceName: conflict.workspaceName,
directoryName: conflict.feedbackDirectoryName,
})}
</li>
))}
</ul>
</AlertDescription>
</div>
</Alert>
)}
</div>
{isEdit && (
@@ -402,7 +402,6 @@ describe("FeedbackDirectory Service", () => {
});
test("unarchives directory", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any);
vi.mocked(prisma.feedbackDirectory.update).mockResolvedValueOnce({} as any);
const result = await updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, {
@@ -410,48 +409,12 @@ describe("FeedbackDirectory Service", () => {
});
expect(result).toBe(true);
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1, mockWorkspaceId2] },
feedbackDirectoryId: { not: mockDirectoryId },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.update).toHaveBeenCalledWith({
where: { id: mockDirectoryId },
data: { isArchived: false },
});
});
test("throws ResourceNotFoundError when unarchiving and directory cannot be loaded", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(null);
await expect(
updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, {
isArchived: false,
})
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.feedbackDirectoryWorkspace.findFirst).not.toHaveBeenCalled();
expect(prisma.feedbackDirectory.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError when unarchiving would assign a workspace to two active directories", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
workspaceId: mockWorkspaceId1,
} as any);
await expect(
updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, {
isArchived: false,
})
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
expect(prisma.feedbackDirectory.update).not.toHaveBeenCalled();
});
test("updates workspace assignments with diff", async () => {
// getFeedbackDirectoryDetails call
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any);
@@ -1,5 +1,5 @@
import "server-only";
import { Prisma, type PrismaClient } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -15,11 +15,6 @@ import {
ZFeedbackDirectoryUpdateInput,
} from "@/modules/ee/feedback-directory/types/feedback-directory";
type FeedbackDirectoryPrismaClient = Pick<
PrismaClient,
"connector" | "feedbackDirectory" | "feedbackDirectoryWorkspace" | "workspace"
>;
/**
* Retrieves all feedback directories for a given organization.
*
@@ -191,59 +186,6 @@ export const getWorkspaceFeedbackDirectoryAccess = reactCache(
}
);
const mapFeedbackDirectoryDetails = (directory: {
id: string;
name: string;
isArchived: boolean;
organizationId: string;
workspaces: { workspaceId: string; workspace: { name: string } }[];
connectors: {
id: string;
name: string;
type: string;
workspaceId: string;
workspace: { name: string };
}[];
}): TFeedbackDirectoryDetails => ({
id: directory.id,
name: directory.name,
isArchived: directory.isArchived,
organizationId: directory.organizationId,
workspaces: directory.workspaces.map((dp) => ({
workspaceId: dp.workspaceId,
workspaceName: dp.workspace.name,
})),
connectors: directory.connectors.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
workspaceId: c.workspaceId,
workspaceName: c.workspace.name,
})),
});
const getFeedbackDirectoryWorkspaceIdsWithClient = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string
): Promise<string[] | null> => {
const directory = await prismaClient.feedbackDirectory.findUnique({
where: { id: directoryId },
select: {
workspaces: {
select: {
workspaceId: true,
},
},
},
});
if (!directory) {
return null;
}
return directory.workspaces.map((workspace) => workspace.workspaceId);
};
export const getFeedbackDirectoryDetails = reactCache(
async (directoryId: string): Promise<TFeedbackDirectoryDetails | null> => {
validateInputs([directoryId, ZId]);
@@ -284,7 +226,23 @@ export const getFeedbackDirectoryDetails = reactCache(
return null;
}
return mapFeedbackDirectoryDetails(directory);
return {
id: directory.id,
name: directory.name,
isArchived: directory.isArchived,
organizationId: directory.organizationId,
workspaces: directory.workspaces.map((dp) => ({
workspaceId: dp.workspaceId,
workspaceName: dp.workspace.name,
})),
connectors: directory.connectors.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
workspaceId: c.workspaceId,
workspaceName: c.workspace.name,
})),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -321,7 +279,7 @@ export const createFeedbackDirectory = async (
if (count !== workspaceIds.length) {
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
}
await assertWorkspacesNotAssignedElsewhere(prisma, undefined, workspaceIds);
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
}
const directory = await prisma.feedbackDirectory.create({
@@ -363,7 +321,7 @@ export const createFeedbackDirectory = async (
* @throws {InvalidInputError} If any workspace does not belong to the organization.
*/
const buildWorkspaceAssignmentPayload = async (
prismaClient: FeedbackDirectoryPrismaClient,
prismaClient: PrismaClient,
directoryId: string,
workspaceIds: string[],
organizationId: string,
@@ -411,12 +369,11 @@ interface UpdateFeedbackDirectoryOptions {
}
const getArchiveUpdate = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string,
isArchived: boolean | undefined
): Promise<Pick<Prisma.FeedbackDirectoryUpdateInput, "isArchived">> => {
if (isArchived === true) {
const connectorCount = await prismaClient.connector.count({
const connectorCount = await prisma.connector.count({
where: { feedbackDirectoryId: directoryId },
});
if (connectorCount > 0) {
@@ -426,13 +383,6 @@ const getArchiveUpdate = async (
}
if (isArchived === false) {
const currentWorkspaceIds = await getFeedbackDirectoryWorkspaceIdsWithClient(prismaClient, directoryId);
if (!currentWorkspaceIds) {
throw new ResourceNotFoundError("FeedbackDirectory", directoryId);
}
await assertWorkspacesNotAssignedElsewhere(prismaClient, directoryId, currentWorkspaceIds);
return { isArchived: false };
}
@@ -440,7 +390,6 @@ const getArchiveUpdate = async (
};
const getWorkspaceAssignmentUpdate = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string,
organizationId: string,
workspaceIds: string[] | undefined
@@ -452,10 +401,10 @@ const getWorkspaceAssignmentUpdate = async (
return { removedWorkspaceIds: [] };
}
const currentWorkspaceIds =
(await getFeedbackDirectoryWorkspaceIdsWithClient(prismaClient, directoryId)) ?? [];
const currentDetails = await getFeedbackDirectoryDetails(directoryId);
const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? [];
const assignmentPayload = await buildWorkspaceAssignmentPayload(
prismaClient,
prisma,
directoryId,
workspaceIds,
organizationId,
@@ -497,13 +446,12 @@ const pauseConnectorsInWorkspaces = async (
* conflict check. Omit it on create every active directory is a conflict.
*/
const assertWorkspacesNotAssignedElsewhere = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string | undefined,
workspaceIds: string[]
): Promise<void> => {
if (workspaceIds.length === 0) return;
const conflicting = await prismaClient.feedbackDirectoryWorkspace.findFirst({
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
where: {
workspaceId: { in: workspaceIds },
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
@@ -546,42 +494,34 @@ export const updateFeedbackDirectory = async (
try {
const { name, workspaceIds, isArchived } = data;
await prisma.$transaction(
async (tx) => {
if (workspaceIds !== undefined) {
await assertWorkspacesNotAssignedElsewhere(tx, directoryId, workspaceIds);
}
if (workspaceIds !== undefined) {
await assertWorkspacesNotAssignedElsewhere(directoryId, workspaceIds);
}
const archiveUpdate = await getArchiveUpdate(tx, directoryId, isArchived);
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
tx,
directoryId,
organizationId,
workspaceIds
);
const payload: Prisma.FeedbackDirectoryUpdateInput = {
...(name !== undefined ? { name } : {}),
...archiveUpdate,
...(workspaceAssignmentUpdate.workspaces
? { workspaces: workspaceAssignmentUpdate.workspaces }
: {}),
};
await tx.feedbackDirectory.update({
where: { id: directoryId },
data: payload,
});
if (options?.pauseConnectorsInRemovedWorkspaces) {
await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
directoryId,
organizationId,
workspaceIds
);
const payload: Prisma.FeedbackDirectoryUpdateInput = {
...(name !== undefined ? { name } : {}),
...archiveUpdate,
...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
};
await prisma.$transaction(async (tx) => {
await tx.feedbackDirectory.update({
where: { id: directoryId },
data: payload,
});
if (options?.pauseConnectorsInRemovedWorkspaces) {
await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
}
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -1,102 +0,0 @@
import { describe, expect, test } from "vitest";
import { getWorkspaceAccessConflictState } from "./workspace-access-conflicts";
const orgWorkspaces = [
{ id: "workspace-b", name: "Beta" },
{ id: "workspace-a", name: "Alpha" },
];
describe("workspace access conflict helpers", () => {
test("shows conflicts when every workspace is assigned to a different active directory", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-b",
feedbackDirectoryId: "directory-2",
feedbackDirectoryName: "Directory B",
},
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-1",
feedbackDirectoryName: "Directory A",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceAccessConflictState(input)).toEqual({
conflictDetails: [
{
workspaceId: "workspace-a",
workspaceName: "Alpha",
feedbackDirectoryName: "Directory A",
},
{
workspaceId: "workspace-b",
workspaceName: "Beta",
feedbackDirectoryName: "Directory B",
},
],
hasSelectableWorkspace: false,
showBlockedExplanation: true,
});
});
test("does not show the blocked explanation when some workspaces are still available", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-1",
feedbackDirectoryName: "Directory A",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceAccessConflictState(input)).toEqual({
conflictDetails: [
{
workspaceId: "workspace-a",
workspaceName: "Alpha",
feedbackDirectoryName: "Directory A",
},
],
hasSelectableWorkspace: true,
showBlockedExplanation: false,
});
});
test("treats assignments to the current directory as selectable", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-current",
feedbackDirectoryName: "Current Directory",
},
{
workspaceId: "workspace-b",
feedbackDirectoryId: "directory-2",
feedbackDirectoryName: "Directory B",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceAccessConflictState(input)).toEqual({
conflictDetails: [
{
workspaceId: "workspace-b",
workspaceName: "Beta",
feedbackDirectoryName: "Directory B",
},
],
hasSelectableWorkspace: true,
showBlockedExplanation: false,
});
});
});
@@ -1,65 +0,0 @@
interface WorkspaceAccessAssignment {
workspaceId: string;
feedbackDirectoryId: string;
feedbackDirectoryName: string;
}
interface WorkspaceOptionSource {
id: string;
name: string;
}
export interface WorkspaceConflictDetail {
workspaceId: string;
workspaceName: string;
feedbackDirectoryName: string;
}
interface WorkspaceAccessConflictInput {
orgWorkspaces: WorkspaceOptionSource[];
workspaceAccessByWorkspace: WorkspaceAccessAssignment[];
currentDirectoryId?: string;
}
interface WorkspaceAccessConflictState {
conflictDetails: WorkspaceConflictDetail[];
hasSelectableWorkspace: boolean;
showBlockedExplanation: boolean;
}
const sortByWorkspaceName = (a: WorkspaceConflictDetail, b: WorkspaceConflictDetail): number =>
a.workspaceName.localeCompare(b.workspaceName, undefined, { sensitivity: "base" });
export const getWorkspaceAccessConflictState = ({
orgWorkspaces,
workspaceAccessByWorkspace,
currentDirectoryId,
}: WorkspaceAccessConflictInput): WorkspaceAccessConflictState => {
const workspaceAccessMap = new Map(
workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
);
let hasSelectableWorkspace = false;
const conflictDetails: WorkspaceConflictDetail[] = [];
for (const workspace of orgWorkspaces) {
const assignment = workspaceAccessMap.get(workspace.id);
if (!assignment || assignment.feedbackDirectoryId === currentDirectoryId) {
hasSelectableWorkspace = true;
continue;
}
conflictDetails.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
feedbackDirectoryName: assignment.feedbackDirectoryName,
});
}
conflictDetails.sort(sortByWorkspaceName);
return {
conflictDetails,
hasSelectableWorkspace,
showBlockedExplanation: conflictDetails.length > 0 && !hasSelectableWorkspace,
};
};
@@ -13,7 +13,7 @@ export const ManageTeam = () => {
const router = useRouter();
const handleManageTeams = () => {
router.push(`${workspaceBasePath}/settings/organization/teams`);
router.push(`${workspaceBasePath}/settings/teams`);
};
return (
@@ -44,8 +44,6 @@ export function ConnectorTypeSelector({
{connectorOptions.map((option) => {
const showNoSurveysAlert =
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
const showApiIngestionSetupAlert =
option.id === "api_ingestion" && selectedType === "api_ingestion";
return (
<div key={option.id} className="space-y-2">
<button
@@ -76,7 +74,6 @@ export function ConnectorTypeSelector({
</div>
</button>
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
{showApiIngestionSetupAlert && <ApiIngestionSetupAlert />}
</div>
);
})}
@@ -97,20 +94,6 @@ export function ConnectorTypeSelector({
);
}
const ApiIngestionSetupAlert = () => {
const { t } = useTranslation();
return (
<Alert variant="info" size="small">
<div className="min-w-0 space-y-1">
<AlertDescription className="overflow-visible whitespace-normal">
<p>{t("workspace.unify.api_ingestion_setup_description")}</p>
</AlertDescription>
</div>
</Alert>
);
};
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
return (
<Alert variant="info" size="small">
@@ -66,9 +66,6 @@ import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksQuestionList } from "./formbricks-question-list";
const API_INGESTION_DOCS_URL = "https://formbricks.com/docs/unify-feedback/api/rest-api";
const FEEDBACK_RECORD_MCP_DOCS_URL = "https://formbricks.com/docs/unify-feedback/api/mcp";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -263,12 +260,12 @@ export const CreateConnectorModal = ({
if (currentStep !== "selectType" || !selectedType) return;
if (selectedType === "api_ingestion") {
window.open(API_INGESTION_DOCS_URL, "_blank", "noopener,noreferrer");
window.open("https://formbricks.com/docs/unify-feedback/api/rest-api", "_blank", "noopener,noreferrer");
return;
}
if (selectedType === "feedback_record_mcp") {
window.open(FEEDBACK_RECORD_MCP_DOCS_URL, "_blank", "noopener,noreferrer");
window.open("https://formbricks.com/docs/unify-feedback/api/mcp", "_blank", "noopener,noreferrer");
return;
}
@@ -482,6 +479,7 @@ export const CreateConnectorModal = ({
workspaceId={workspaceId}
/>
)}
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
<FormProvider {...formbricksForm}>
<form
+55
View File
@@ -117,6 +117,61 @@ describe("storage service", () => {
}
});
test("should generate scoped private upload URL when path segments are provided", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForUpload(
"test-doc.pdf",
"ws-123",
"application/pdf",
"private" as TAccessType,
1024 * 1024 * 10,
["surveys", "survey-123", "questions", "question-123"]
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.fileUrl).toBe(
`/storage/ws-123/private/surveys/survey-123/questions/question-123/test-doc--fid--${mockUUID}.pdf`
);
}
expect(getSignedUploadUrl).toHaveBeenCalledWith(
`test-doc--fid--${mockUUID}.pdf`,
"application/pdf",
"ws-123/private/surveys/survey-123/questions/question-123",
1024 * 1024 * 10
);
});
test.each(["", ".", "..", "bad segment", "bad/segment", "bad\\segment", "bad?segment", "bad#segment"])(
"should reject unsafe scoped private upload path segment %s",
async (unsafeSegment) => {
const result = await getSignedUrlForUpload(
"test-doc.pdf",
"ws-123",
"application/pdf",
"private" as TAccessType,
1024 * 1024 * 10,
["surveys", unsafeSegment]
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(StorageErrorCode.InvalidInput);
}
expect(getSignedUploadUrl).not.toHaveBeenCalled();
}
);
test("should properly sanitize filenames with special characters like # in URL", async () => {
const mockSignedUrlResponse = {
ok: true,
+17 -9
View File
@@ -10,15 +10,18 @@ import {
getSignedUploadUrl,
} from "@formbricks/storage";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TAccessType } from "@formbricks/types/storage";
import { type TAccessType } from "@formbricks/types/storage";
import { sanitizeFileName } from "./utils";
const SAFE_FILE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
export const getSignedUrlForUpload = async (
fileName: string,
workspaceId: string,
fileType: string,
accessType: TAccessType,
maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB
maxFileUploadSize: number = 1024 * 1024 * 10, // 10MB
filePathSegments: string[] = []
): Promise<
Result<
{
@@ -34,17 +37,19 @@ export const getSignedUrlForUpload = async (
if (!safeFileName) {
return err({ code: StorageErrorCode.InvalidInput });
}
if (filePathSegments.some((segment) => !SAFE_FILE_PATH_SEGMENT.test(segment))) {
return err({ code: StorageErrorCode.InvalidInput });
}
const encodedFilePathSegments = filePathSegments.map((segment) => encodeURIComponent(segment));
const fileNameWithoutExtension = safeFileName.split(".").slice(0, -1).join(".");
const fileExtension = safeFileName.split(".").pop();
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
const filePath = [workspaceId, accessType, ...filePathSegments].join("/");
const signedUrlResult = await getSignedUploadUrl(
updatedFileName,
fileType,
`${workspaceId}/${accessType}`,
maxFileUploadSize
);
const signedUrlResult = await getSignedUploadUrl(updatedFileName, fileType, filePath, maxFileUploadSize);
if (!signedUrlResult.ok) {
return signedUrlResult;
@@ -54,7 +59,10 @@ export const getSignedUrlForUpload = async (
return ok({
signedUrl: signedUrlResult.data.signedUrl,
presignedFields: signedUrlResult.data.presignedFields,
fileUrl: `/storage/${workspaceId}/${accessType}/${encodeURIComponent(updatedFileName)}`,
fileUrl: `/storage/${workspaceId}/${accessType}/${[
...encodedFilePathSegments,
encodeURIComponent(updatedFileName),
].join("/")}`,
});
} catch (error) {
logger.error({ error }, "Error getting signed url for upload");
+161 -8
View File
@@ -7,10 +7,12 @@ import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
isAllowedFileExtension,
isValidImageFile,
parseStorageFileUrl,
resolveStorageUrl,
resolveStorageUrlAuto,
resolveStorageUrlsInObject,
sanitizeFileName,
validateClientFileUploads,
validateFileUploads,
validateSingleFile,
validateSurveyAllowsFileUpload,
@@ -369,7 +371,11 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
).toEqual({
ok: true,
});
});
test("should allow a matching extension from a legacy file upload question", () => {
@@ -381,7 +387,9 @@ describe("storage utils", () => {
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "image.png", questionId: "question1", questions })
).toEqual({ ok: true });
});
test("should allow any globally safe extension when a file upload has no survey restriction", () => {
@@ -398,7 +406,11 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
).toEqual({
ok: true,
});
});
test("should reject surveys without file upload blocks or questions", () => {
@@ -421,7 +433,9 @@ describe("storage utils", () => {
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "question1", blocks, questions })
).toEqual({
ok: false,
reason: "no_file_upload_question",
});
@@ -447,7 +461,9 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
@@ -473,7 +489,11 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
).toEqual({
ok: true,
});
});
test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => {
@@ -484,15 +504,148 @@ describe("storage utils", () => {
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "report", questionId: "question1", questions })
).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "malware.exe", questionId: "question1", questions })
).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
});
test("should reject a question id that is not the file upload element", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
allowedFileExtensions: ["pdf"],
},
],
},
] as unknown as TSurveyBlock[];
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
).toEqual({
ok: false,
reason: "file_upload_question_not_found",
});
});
});
describe("validateClientFileUploads", () => {
const workspaceId = "clxworkspace123";
const surveyId = "clxsurvey123";
const questionId = "file_question";
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: questionId,
type: "fileUpload" as const,
allowedFileExtensions: ["pdf"],
},
],
},
] as unknown as TSurveyBlock[];
test("should accept scoped private storage URLs for the matching survey and question", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/report--fid--abc.pdf`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(true);
});
test("should reject unscoped legacy storage URLs for new client submissions", () => {
const responseData = {
[questionId]: [`/storage/${workspaceId}/private/report--fid--abc.pdf`],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject scoped URLs for a different survey", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/otherSurvey/questions/${questionId}/report--fid--abc.pdf`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject scoped URLs for a different question", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/otherQuestion/report--fid--abc.pdf`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject external URLs", () => {
const responseData = {
[questionId]: ["https://example.com/report--fid--abc.pdf"],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject file extensions not allowed by the matching upload question", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/image--fid--abc.png`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
});
describe("parseStorageFileUrl", () => {
test("should parse nested relative storage URLs", () => {
expect(
parseStorageFileUrl(
"/storage/workspace-123/private/surveys/survey-123/questions/question-123/report.pdf"
)
).toEqual({
storageId: "workspace-123",
accessType: "private",
fileName: "surveys/survey-123/questions/question-123/report.pdf",
});
});
test("should parse absolute storage URLs", () => {
expect(parseStorageFileUrl("https://example.com/storage/workspace-123/public/report.pdf")).toEqual({
storageId: "workspace-123",
accessType: "public",
fileName: "report.pdf",
});
});
test.each([
"https://example.com/not-storage/workspace-123/private/report.pdf",
"/storage/workspace-123/internal/report.pdf",
"/storage/workspace-123/private",
"not a url",
])("should reject invalid storage URL %s", (fileUrl) => {
expect(parseStorageFileUrl(fileUrl)).toBeNull();
});
});
describe("isValidImageFile", () => {
+156 -12
View File
@@ -1,7 +1,11 @@
import "server-only";
import { type StorageError, StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
import {
type TAccessType,
type TAllowedFileExtension,
ZAllowedFileExtension,
} from "@formbricks/types/storage";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -120,7 +124,7 @@ export type TSurveyFileUploadPermissionResult =
}
| {
ok: false;
reason: "no_file_upload_question" | "file_extension_not_allowed";
reason: "no_file_upload_question" | "file_upload_question_not_found" | "file_extension_not_allowed";
};
const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => {
@@ -132,21 +136,33 @@ const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExte
return extensionValidation.success ? extensionValidation.data : null;
};
export const validateSurveyAllowsFileUpload = ({
fileName,
const getSurveyFileUploadConfigs = ({
blocks,
questions,
}: {
fileName: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): TSurveyFileUploadPermissionResult => {
const fileUploadConfigs = [
}): TSurveyFileUploadElement[] => {
return [
...(blocks ?? [])
.flatMap((block) => block.elements)
.filter((element) => element.type === TSurveyElementTypeEnum.FileUpload),
...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload),
] as TSurveyFileUploadElement[];
};
export const validateSurveyAllowsFileUpload = ({
fileName,
questionId,
blocks,
questions,
}: {
fileName: string;
questionId: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): TSurveyFileUploadPermissionResult => {
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
if (fileUploadConfigs.length === 0) {
return {
@@ -155,6 +171,15 @@ export const validateSurveyAllowsFileUpload = ({
};
}
const fileUploadConfig = fileUploadConfigs.find((config) => config.id === questionId);
if (!fileUploadConfig) {
return {
ok: false,
reason: "file_upload_question_not_found",
};
}
const fileExtension = getAllowedFileExtensionFromFileName(fileName);
if (!fileExtension) {
@@ -164,11 +189,9 @@ export const validateSurveyAllowsFileUpload = ({
};
}
const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => {
const { allowedFileExtensions } = fileUploadConfig;
return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
});
const { allowedFileExtensions } = fileUploadConfig;
const isFileExtensionAllowed =
allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
return isFileExtensionAllowed
? { ok: true }
@@ -178,6 +201,127 @@ export const validateSurveyAllowsFileUpload = ({
};
};
const getStorageUrlPathSegments = (fileUrl: string): string[] | null => {
if (!fileUrl.startsWith("/storage/")) return null;
const pathWithoutSearch = fileUrl.split(/[?#]/)[0];
return pathWithoutSearch.split("/").filter(Boolean);
};
type TParsedStorageFileUrl = {
storageId: string;
accessType: TAccessType;
fileName: string;
};
export const parseStorageFileUrl = (fileUrl: string): TParsedStorageFileUrl | null => {
let pathname: string;
try {
pathname = fileUrl.startsWith("/storage/") ? fileUrl : new URL(fileUrl).pathname;
} catch {
return null;
}
const pathWithoutSearch = pathname.split(/[?#]/)[0];
if (!pathWithoutSearch.startsWith("/storage/")) return null;
const [storageSegment, storageId, accessType, ...fileNameSegments] = pathWithoutSearch
.split("/")
.filter(Boolean);
const fileName = fileNameSegments.join("/");
if (
storageSegment !== "storage" ||
!storageId ||
!fileName ||
(accessType !== "private" && accessType !== "public")
) {
return null;
}
return { storageId, accessType, fileName };
};
const isScopedPrivateUploadUrl = ({
fileUrl,
workspaceId,
surveyId,
questionId,
}: {
fileUrl: string;
workspaceId: string;
surveyId: string;
questionId: string;
}): boolean => {
const segments = getStorageUrlPathSegments(fileUrl);
if (!segments || segments.length !== 8) return false;
const [
storageSegment,
storageWorkspaceId,
accessType,
surveysSegment,
storageSurveyId,
questionsSegment,
storageQuestionId,
fileName,
] = segments;
return (
storageSegment === "storage" &&
storageWorkspaceId === workspaceId &&
accessType === "private" &&
surveysSegment === "surveys" &&
storageSurveyId === surveyId &&
questionsSegment === "questions" &&
storageQuestionId === questionId &&
Boolean(fileName)
);
};
export const validateClientFileUploads = ({
data,
workspaceId,
surveyId,
blocks,
questions,
}: {
data?: TResponseData;
workspaceId: string;
surveyId: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): boolean => {
if (!data) return true;
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
for (const fileUploadConfig of fileUploadConfigs) {
const fileUrls = data[fileUploadConfig.id];
if (fileUrls === undefined) continue;
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
for (const fileUrl of fileUrls) {
if (!validateSingleFile(fileUrl, fileUploadConfig.allowedFileExtensions)) return false;
if (
!isScopedPrivateUploadUrl({
fileUrl,
workspaceId,
surveyId,
questionId: fileUploadConfig.id,
})
) {
return false;
}
}
}
return true;
};
export const isValidImageFile = (fileUrl: string): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName || fileName.endsWith(".")) return false;
@@ -1,7 +1,6 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { CopyIcon, EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -10,12 +9,9 @@ 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 {
@@ -46,11 +42,9 @@ 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`;
@@ -91,29 +85,6 @@ 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;
}
@@ -149,22 +120,6 @@ 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,14 +5,9 @@ import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
interface GoBackButtonProps {
url?: string;
}
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
export const GoBackButton = ({ url }: { url?: string }) => {
const router = useRouter();
const { t } = useTranslation();
return (
<Button
size="sm"
@@ -22,7 +17,6 @@ export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
router.push(url);
return;
}
router.back();
}}>
<ArrowLeftIcon />
@@ -34,6 +34,7 @@ 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")} />
+1 -1
View File
@@ -122,7 +122,7 @@
"react-turnstile": "1.1.5",
"react-use": "17.6.0",
"recharts": "2.15.3",
"sanitize-html": "2.17.4",
"sanitize-html": "2.17.3",
"server-only": "0.0.1",
"sharp": "0.34.5",
"stripe": "20.4.1",
+1 -3
View File
@@ -224,9 +224,7 @@ test.describe("Survey overview", () => {
});
await page.locator("[data-testid='survey-dropdown-trigger']").click();
// 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("Duplicate", { exact: true })).toHaveCount(0);
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);
+33 -12
View File
@@ -7,7 +7,6 @@ import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
const MOCK_STORAGE_UPLOAD_PATH = "/__playwright__/mock-storage-upload";
const MOCK_STORAGE_FILE_PATH = "/storage/playwright-mock";
type MockStorageFileFixture = {
name: string;
@@ -44,11 +43,19 @@ const DEFAULT_MOCK_STORAGE_FILE_FIXTURE: MockStorageFileFixture = {
),
};
const getMockStorageFileUrl = (
appOrigin: string,
fileName: string,
accessType: "public" | "private"
): string => {
const getMockStorageFileUrl = ({
appOrigin,
fileName,
accessType,
storageId = "playwright-mock",
filePathSegments = [],
}: {
appOrigin: string;
fileName: string;
accessType: "public" | "private";
storageId?: string;
filePathSegments?: string[];
}): string => {
if (accessType === "public") {
const fixture = PLAYWRIGHT_STORAGE_FILE_FIXTURES.get(fileName);
@@ -57,7 +64,7 @@ const getMockStorageFileUrl = (
}
}
return `${MOCK_STORAGE_FILE_PATH}/${accessType}/${encodeURIComponent(fileName)}`;
return `/storage/${storageId}/${accessType}/${[...filePathSegments, encodeURIComponent(fileName)].join("/")}`;
};
/**
@@ -86,7 +93,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
presignedFields: {
key: fileName,
},
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "public"),
fileUrl: getMockStorageFileUrl({ appOrigin, fileName, accessType: "public" }),
signingData: null,
updatedFileName: fileName,
},
@@ -113,9 +120,17 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
return;
}
const payload = route.request().postDataJSON() as { fileName?: string } | undefined;
const payload = route.request().postDataJSON() as
| { fileName?: string; surveyId?: string; questionId?: string }
| undefined;
const fileName = payload?.fileName ?? "uploaded-file.bin";
const appOrigin = new URL(route.request().url()).origin;
const requestUrl = new URL(route.request().url());
const appOrigin = requestUrl.origin;
const workspaceId = requestUrl.pathname.split("/").filter(Boolean)[3] ?? "playwright-mock";
const filePathSegments =
payload?.surveyId && payload?.questionId
? ["surveys", payload.surveyId, "questions", payload.questionId]
: [];
await route.fulfill({
status: 200,
@@ -126,7 +141,13 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
presignedFields: {
key: fileName,
},
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "private"),
fileUrl: getMockStorageFileUrl({
appOrigin,
fileName,
accessType: "private",
storageId: workspaceId,
filePathSegments,
}),
signingData: null,
updatedFileName: fileName,
},
@@ -148,7 +169,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
});
});
await page.route(`**${MOCK_STORAGE_FILE_PATH}/**`, async (route) => {
await page.route("**/storage/**", async (route) => {
if (!["GET", "HEAD"].includes(route.request().method())) {
await route.fallback();
return;
+5 -2
View File
@@ -2,11 +2,14 @@ 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:078652b37649ba5bb72f7463709356c08f464cb989270b97d095b8243c0e58b9
generated: "2026-05-21T12:52:44.141547+05:30"
digest: sha256:d93a29cb9cbef513db410ded5aa199f219c5b25ce1870e1e049dc470acc34235
generated: "2026-04-08T00:33:09.875832+05:30"
+7 -3
View File
@@ -1,6 +1,6 @@
apiVersion: v2
name: formbricks
description: A Helm chart for Formbricks with PostgreSQL, Valkey
description: A Helm chart for Formbricks with PostgreSQL, Redis
type: application
@@ -8,14 +8,14 @@ type: application
version: 0.0.0-dev
# This is the version number of the application being deployed.
appVersion: "5.0.0-rc.1"
appVersion: "3.7.0"
icon: https://formbricks.com/favicon.ico
keywords:
- formbricks
- postgresql
- valkey
- redis
home: https://formbricks.com/docs/self-hosting/setup/kubernetes
maintainers:
@@ -27,6 +27,10 @@ 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"
+216 -237
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: 5.0.0-rc.1](https://img.shields.io/badge/AppVersion-5.0.0--rc.1-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: 3.7.0](https://img.shields.io/badge/AppVersion-3.7.0-informational?style=flat-square)
A Helm chart for Formbricks with PostgreSQL, Valkey
A Helm chart for Formbricks with PostgreSQL, Redis
**Homepage:** <https://formbricks.com/docs/self-hosting/setup/kubernetes>
@@ -17,8 +17,9 @@ A Helm chart for Formbricks with PostgreSQL, Valkey
| 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 | envoyRedis | 20.11.2 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
## Envoy bundle modes
@@ -31,7 +32,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 bundled app Valkey deployment.
separate from the existing app `redis` dependency.
- 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.
@@ -54,8 +55,7 @@ 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.
- 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_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- 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,234 +92,213 @@ 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.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"` | |
| 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"` | |
+17 -139
View File
@@ -9,120 +9,46 @@ 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}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
description: `Number of NPS promoters (score 9-10)`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
description: `Number of NPS detractors (score 0-6)`,
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
description: `Number of NPS passives (score 7-8)`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(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
(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
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
},
npsAverage: {
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
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)`,
description: `Average NPS score`,
},
},
@@ -151,70 +77,22 @@ 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`,
},
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: {
npsValue: {
sql: `value_number`,
type: `number`,
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.`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `submission_id`,
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
description: `Unique identifier linking related feedback records`,
},
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.redisMasterName" . }}`
- **Host**: `{{ include "formbricks.name" . }}-redis-master`
- **Port**: `6379`
{{- else if .Values.redis.externalRedisUrl }}
You're using an external Redis instance.
-49
View File
@@ -92,26 +92,6 @@ 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.
@@ -125,26 +105,6 @@ 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 }}
@@ -329,15 +289,6 @@ 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,12 +70,8 @@ spec:
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if or .Values.cube.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
{{- if .Values.cube.envFrom }}
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:
@@ -101,7 +97,12 @@ spec:
{{- end }}
env:
{{- range $key, $value := .Values.cube.env }}
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
- 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 }}
{{- end }}
volumeMounts:
- name: cube-config

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