Compare commits

...

63 Commits

Author SHA1 Message Date
Dhruwang Jariwala 062a59efed fix: harden Helm release secret lookups (#8119) 2026-05-22 17:27:29 +05:30
Bhagya Amarasinghe b577c151c8 fix: harden Helm release secret lookups 2026-05-22 17:09:07 +05:30
Dhruwang Jariwala f39f12ec1c feat: [Backport] cascade delete Hub feedback records on org deletion (#8055) (#8116) 2026-05-22 16:22:43 +05:30
Dhruwang 01fb38cff8 chore: bump Hub image to 0.4.0 for tenant-scoped delete endpoint
Cascade delete in deleteOrganization calls DELETE /v1/tenants/{tenant_id}/data,
which is first available in Hub 0.4.0. Bumps the dev-compose default tag and the
Helm chart digest/tag so deployments don't silently land on the older Hub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:50 +05:30
Dhruwang 71e57947b9 fix: switch Hub purge to DELETE /v1/tenants/{tenant_id}/data
Hub#77 ships the proper tenant-data purge endpoint that nukes feedback
records, derived embeddings, and webhooks in one idempotent call. Swap
the placeholder bulkDelete cast for a direct client.delete<>() against
the new path and surface the per-resource counts.

Renames deleteFeedbackRecordsByTenant → deleteHubTenantData to reflect
that it deletes more than feedback records. Org-deletion call site and
tests updated accordingly. Best-effort error handling unchanged.
2026-05-22 16:08:49 +05:30
Dhruwang 5f6d6d53b2 feat: cascade delete Hub feedback records on org deletion (ENG-973)
Add a tenant-scoped purge wrapper in the Hub gateway and call it for
each FeedbackDirectory after an organization is deleted, so Hub records
do not become orphaned when the local cascade clears the directory rows.

Depends on a Hub-side change to accept a tenant-only bulkDelete payload;
the call is best-effort and failures are logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:49 +05:30
Anshuman Pandey b79758ee49 fix: [Backport] json payload limit (#8115)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 12:55:14 +04:00
Bhagya Amarasinghe 25b0b89e86 fix: use Valkey for bundled Helm Redis (backport #8092) (#8105) 2026-05-22 11:39:11 +05:30
Bhagya Amarasinghe f228ce7eb6 fix: address Helm Valkey review comments 2026-05-22 11:36:36 +05:30
Bhagya Amarasinghe 67ae13a61a fix: use Valkey for bundled Helm Redis 2026-05-22 11:36:31 +05:30
Johannes 70e72ab0de fix: backport removal of isAIDataAnalysisEnabled to v5 (#8112) 2026-05-21 20:38:04 +02:00
Johannes 07d1d918ba fix: backport CSAT and CES summary filter icons to 5.0 (#8058)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 17:56:52 +02:00
Bhagya Amarasinghe a98a0a8e73 fix: pin DNS and block redirects on webhook delivery (backport #8095) (#8106) 2026-05-21 19:10:59 +05:30
Bhagya Amarasinghe a5a67a05de fix: order Helm Hub migrations after Prisma (backport #8104) (#8107) 2026-05-21 19:10:32 +05:30
Bhagya Amarasinghe 4876e107f8 fix: order Helm Hub migrations after Prisma 2026-05-21 18:33:44 +05:30
Bhagya Amarasinghe 5db616ac07 fix(webhooks): ignore dispatcher cleanup failures 2026-05-21 18:33:25 +05:30
pandeymangg fa1ccdb2c3 pin DNS and block redirects on webhook delivery 2026-05-21 18:33:25 +05:30
Dhruwang Jariwala 6183ab4744 fix: backport #8101 reserved contact keys and segment errors to 5.0 (#8103) 2026-05-21 17:12:03 +05:30
Dhruwang Jariwala 431a3d8a76 fix: allow enterprise oauth display names (#8100) 2026-05-21 16:30:16 +05:30
Dhruwang Jariwala 91ab958379 fix: chart date range type switch + presets include today (backport #8096) (#8097) 2026-05-21 16:26:59 +05:30
Dhruwang Jariwala e5df832653 fix: [Backport] adds close button on response error screen (#8098) 2026-05-21 16:26:09 +05:30
Johannes 0909c38eb1 code rabbit comments
(cherry picked from commit 8fb2287ae7)
2026-05-21 12:46:03 +02:00
Johannes 08b0d95295 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994)
Block creation of reserved safe-identifier contact keys across API, SDK, and CSV flows to prevent collisions ahead of the v5.1 migration, and surface clearer personal-link/segment validation errors instead of unknown failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 9da9f1582c)
2026-05-21 12:46:03 +02:00
pandeymangg a49e989413 refactor: replace custom functions with date-fns 2026-05-21 15:57:30 +05:30
Cursor Agent 9445b2f482 fix: restrict user name whitespace 2026-05-21 10:22:24 +00:00
Cursor Agent f07d832516 test: anonymize oauth display name fixture 2026-05-21 10:22:24 +00:00
Cursor Agent e615c692a9 fix: allow enterprise oauth display names
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:22:24 +00:00
Dhruwang Jariwala a9a910d15c fix: AI translation rich-text editors stay empty (backport #8084) (#8091) 2026-05-21 12:09:33 +02:00
pandeymangg c425e7aff4 adds close button on response error screen 2026-05-21 15:18:35 +05:30
Dhruwang a83a54a24a fix: chart date range type switch + presets include today (ENG-1034, ENG-1035)
ENG-1034: in the chart editor, switching the date range type from Custom
back to Preset would leave Update chart disabled because the toggle only
updated local UI state and never propagated the change to the parent
config. hasConfigChanged compared two queries that still held the old
custom [Date, Date] range, so it stayed false. The new
handleDateRangeTypeChange seats the parent timeDimension.dateRange to
match the chosen type (preset string or [start, end] tuple) on toggle
so hasConfigChanged can re-evaluate.

ENG-1035: Cube v1.6.6's native "last 7 days" / "last 30 days" / "this
month" presets anchor to end-of-yesterday and exclude today, which
diverges from every other analytics tool (GA, Mixpanel, PostHog, ...).
Added expandPresetDateRanges that rewrites known preset strings to
explicit inclusive [YYYY-MM-DD, YYYY-MM-DD] tuples at the Cube boundary
only. Stored client queries keep the preset string so hasConfigChanged
stays consistent and saved charts round-trip back to the Preset UI.
Unknown preset strings pass through unchanged.
2026-05-21 14:54:57 +05:30
Dhruwang Jariwala f7890eaec3 fix: backport billing-only settings access to 5.0 (#8090) 2026-05-21 13:02:25 +05:30
Dhruwang Jariwala 8cd3187eff fix: backport settings back navigation to 5.0 (#8089) 2026-05-21 13:02:09 +05:30
Dhruwang Jariwala 83bccc7ded fix: backport Cube API secret Helm defaults to 5.0 (#8088) 2026-05-21 12:50:13 +05:30
Dhruwang Jariwala 00aa6d5247 fix: [Backport] backports removal of timestamps from client responses api (#8087) 2026-05-21 12:45:12 +05:30
Dhruwang Jariwala 0657c94ee5 fix: [Backport] excel injection backport (#8086) 2026-05-21 12:44:46 +05:30
Johannes a36cef2936 fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit c0bf2ab7cc)
2026-05-21 07:11:50 +00:00
Johannes 467af8b6ef fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit 13c9677edd)
2026-05-21 07:09:46 +00:00
Dhruwang Jariwala d0e057eac1 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (backport #8061) (#8085) 2026-05-21 12:30:59 +05:30
Bhagya Amarasinghe ef1f5a2b12 fix: wire Cube API secret into Helm defaults
(cherry picked from commit 0e65278af7)
2026-05-21 07:00:06 +00:00
pandeymangg 770041923f backports removal of timestamps from client responses api 2026-05-21 12:14:11 +05:30
pandeymangg 3e66ff25a1 backports excel injection fix 2026-05-21 12:08:05 +05:30
Dhruwang Jariwala c979909da9 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:00:28 +05:30
Dhruwang Jariwala 010d96ebcd fix: harden Helm env value rendering (backport #8070) (#8078) 2026-05-21 11:49:30 +05:30
Dhruwang Jariwala a0b3054f4a fix: [Backport] responseId client api fix (#8083) 2026-05-21 11:47:34 +05:30
Dhruwang Jariwala 02d3cd2af3 fix: update Helm chart default image tag (backport #8072) (#8081) 2026-05-21 11:17:40 +05:30
Dhruwang Jariwala 2ef4eb4345 fix: require Cube API secret in compose (backport #8071) (#8080) 2026-05-21 11:17:25 +05:30
Dhruwang Jariwala 093757b386 fix: render scheduled-plan-change description placeholders correctly (backport #8064) (#8077) 2026-05-21 11:17:10 +05:30
pandeymangg 64f8746940 responseId api fix 2026-05-21 11:13:08 +05:30
Dhruwang Jariwala 851616078a fix: gate AI chart generation on smartTools, not dataAnalysis (backport #8060) (#8076) 2026-05-21 11:01:47 +05:30
Dhruwang Jariwala 06d5313629 fix: route Manage Teams and integration OAuth callbacks to settings (backport #8059) (#8075) 2026-05-21 11:01:34 +05:30
Bhagya Amarasinghe 7834c21d39 fix: update Helm chart default image tag (#8072) 2026-05-21 10:58:09 +05:30
Bhagya Amarasinghe f98ca39035 fix: require Cube API secret in compose (#8071) 2026-05-21 10:57:57 +05:30
Bhagya Amarasinghe 48f928b1bf fix: harden Helm env value rendering (#8070) 2026-05-21 10:48:28 +05:30
Dhruwang Jariwala f5dfb4739c fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:53 +05:30
Anshuman Pandey 5a1fc01388 fix: [Backport] client environment api sdk fixes (#8074) 2026-05-21 09:16:53 +04:00
Dhruwang Jariwala 77a39c13fa fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:17 +05:30
Dhruwang Jariwala 5a12539c75 fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:45:43 +05:30
Dhruwang Jariwala 74e0fba757 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0] (#8069) 2026-05-21 10:25:56 +05:30
Dhruwang Jariwala d9c2756185 fix: sync chart Cube feedback schema (backport #8057 to release/5.0) (#8066) 2026-05-21 10:20:53 +05:30
Matti Nannt 88ad5c8625 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0]
doesContactExist checked contact existence by id only, allowing a caller
to bind a display in one workspace to a contact from a different workspace.
Add workspaceId filter and rename to doesContactExistInWorkspace.

Backport of #8048 to release/5.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:27:48 +02:00
Bhagya Amarasinghe 610beee7eb fix: sync chart Cube feedback schema
Backport of #8057 to release/5.0 (excludes test file changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:55:59 +05:30
Javi Aguilar db0e2bb105 fix: add CSAT and CES summary filter icons (backport #8056) (#8063) 2026-05-20 15:05:47 +02:00
Johannes 419ceef413 fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 14:38:23 +02:00
184 changed files with 2915 additions and 948 deletions
+87 -5
View File
@@ -31,14 +31,14 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Get the latest release tag from GitHub API with error handling
echo "Fetching latest release from GitHub API..."
# Use curl with error handling - API returns 404 if no releases exist
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
if [[ "$http_code" == "404" ]]; then
echo "⚠️ No previous releases found (404). This appears to be the first release."
echo "latest_release=" >> $GITHUB_OUTPUT
@@ -55,7 +55,7 @@ jobs:
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
fi
echo "Current release tag: ${{ github.event.release.tag_name }}"
- name: Compare release tags
@@ -65,7 +65,7 @@ jobs:
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
run: |
set -euo pipefail
# Handle first release case (no previous releases)
if [[ -z "${LATEST_TAG}" ]]; then
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
@@ -156,6 +156,87 @@ jobs:
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
update-helm-app-version:
name: Create Helm app version update
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- helm-chart-release
if: ${{ !github.event.release.prerelease }}
permissions:
contents: write
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Prepare Helm app version update
id: update
env:
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
echo "Helm chart appVersion already matches ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Helm app version PR
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
branch="chore/update-helm-app-version-${VERSION}"
title="chore: update Helm app version to ${VERSION}"
body_file="$(mktemp)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
git commit -m "$title"
git push --force-with-lease origin "$branch"
cat > "$body_file" <<EOF
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
Release candidates and pre-releases do not create this source update.
EOF
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
else
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
fi
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
@@ -165,6 +246,7 @@ jobs:
- docker-build-cloud
- helm-chart-release
- move-stable-tag
- update-helm-app-version
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
+20 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
version: v3.15.4
- name: Log in to GitHub Container Registry
env:
@@ -70,6 +70,25 @@ jobs:
echo "✅ Successfully updated Chart.yaml"
- name: Validate default Formbricks image tag
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
rendered="$(helm template qa charts/formbricks \
--set formbricks.webappUrl=https://qa.example.com \
--show-only templates/deployment.yaml \
--show-only templates/migration-job.yaml)"
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
if [[ "$image_count" -ne 2 ]]; then
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
exit 1
fi
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
@@ -194,7 +194,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
href: `/workspaces/${workspace.id}/settings/workspace/general`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -467,7 +467,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton />
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
{/* Settings sidebar content */}
@@ -335,6 +335,7 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -373,12 +374,14 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -1,4 +1,11 @@
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
};
@@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
@@ -1,3 +1,18 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export default PricingPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -12,8 +13,9 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -66,11 +66,6 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -1,9 +1,10 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -11,15 +12,19 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -1 +1,11 @@
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
@@ -57,7 +57,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
@@ -66,7 +65,6 @@ describe("organization AI settings actions", () => {
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
@@ -114,18 +112,15 @@ describe("organization AI settings actions", () => {
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
@@ -194,7 +189,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
@@ -71,12 +71,11 @@ export const updateOrganizationNameAction = authenticatedActionClient
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
"isAISmartToolsEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
@@ -90,16 +89,10 @@ const resolveOrganizationAISettings = ({
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
};
};
@@ -50,29 +50,18 @@ export const AISettingsToggle = ({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
const handleToggle = async (checked: boolean) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
setLoadingField("isAISmartToolsEnabled");
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
data: { isAISmartToolsEnabled: checked },
});
if (response?.data) {
@@ -122,7 +111,7 @@ export const AISettingsToggle = ({
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
onToggle={handleToggle}
htmlId="ai-smart-tools-toggle"
title={t("workspace.settings.general.ai_smart_tools_enabled")}
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
@@ -130,16 +119,6 @@ export const AISettingsToggle = ({
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("workspace.settings.general.ai_data_analysis_enabled")}
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
@@ -1,3 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -8,7 +9,6 @@ import {
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
@@ -26,8 +26,9 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -36,14 +37,11 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
]);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -4,7 +4,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
export default TeamsPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
throw new InvalidInputError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -11,6 +11,7 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -25,6 +26,7 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
@@ -103,6 +105,8 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
@@ -34,7 +34,7 @@ export const GET = async (req: Request) => {
return responses.unauthorizedResponse();
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
@@ -102,7 +102,7 @@ export const GET = async (req: Request) => {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(`${WEBAPP_URL}/${basePath}/integrations/google-sheets`);
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
@@ -1,6 +1,7 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -32,7 +33,25 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
const jsonInput = await req.json();
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
workspaceId,
@@ -103,6 +103,7 @@ describe("getWorkspaceStateData", () => {
id: workspaceId,
appSetupCompleted: true,
workspaceSettings: {
id: workspaceId,
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -111,7 +112,14 @@ describe("getWorkspaceStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
surveys: mockWorkspaceData.surveys,
// `survey.name` is replaced with a back-compat placeholder; segment was
// null in the mock so the sanitized segment stays null.
surveys: [
{
...mockWorkspaceData.surveys[0],
name: "[deprecated] survey name omitted from public API - will be removed soon",
},
],
actionClasses: mockWorkspaceData.actionClasses,
});
@@ -211,6 +219,7 @@ describe("getWorkspaceStateData", () => {
const result = await getWorkspaceStateData(workspaceId);
expect(result.workspace.workspaceSettings).toEqual({
id: workspaceId,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
@@ -42,6 +42,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
where: { id: workspaceId },
select: {
id: true,
legacyEnvironmentId: true,
appSetupCompleted: true,
recontactDays: true,
clickOutsideClose: true,
@@ -72,7 +73,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
select: {
id: true,
welcomeCard: true,
// name intentionally omitted — internal label not needed by the SDK
// `name` deliberately not selected — internal label not needed by the
// SDK and replaced with a fixed placeholder below so older SDKs that
// decoded `Survey.name` as a required field keep working.
questions: true,
blocks: true,
variables: true,
@@ -99,9 +102,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
// Only need to know if any filters exist so we can compute
// `hasFilters`. Real filter values, segment title/description, and
// surveys-list relation are never exposed to clients.
segment: {
select: {
id: true,
@@ -135,17 +138,46 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
throw new ResourceNotFoundError("workspace", workspaceId);
}
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
// Backwards-compat response shape for SDKs from before PR #7931. Those
// clients decoded `survey.name` and the full `segment` object as required
// fields, so the response must still carry that shape — but every field
// that could leak sensitive targeting data is replaced with a placeholder.
// The actual segment-membership check happens server-side (segment IDs in
// POST /user); SDKs only inspect `filters.length` / `hasFilters` locally.
//
// `environmentId` mirrors `legacyEnvironmentId ?? workspace.id`, matching
// the `/me` endpoints' pattern so migrated workspaces keep returning the
// original env ID older clients persisted.
const legacyOrCurrentId = workspaceData.legacyEnvironmentId ?? workspaceData.id;
const placeholderDate = new Date(0);
const placeholderFilter = {
id: "placeholder",
connector: null,
resource: {
id: "placeholder",
root: { type: "device", deviceType: "phone" },
value: "deprecated",
qualifier: { operator: "equals" },
},
};
const transformedSurveys = workspaceData.surveys.map((survey) => {
const minimalSegment = survey.segment
const realHasFilters =
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
const sanitizedSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
title: "[deprecated] segment title omitted from public API - will be removed soon",
description: null,
isPrivate: true,
filters: realHasFilters ? [placeholderFilter] : [],
environmentId: legacyOrCurrentId,
workspaceId: legacyOrCurrentId,
createdAt: placeholderDate,
updatedAt: placeholderDate,
surveys: [],
hasFilters: realHasFilters,
}
: null;
@@ -155,7 +187,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
segment: null,
});
return { ...transformed, segment: minimalSegment };
return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
segment: sanitizedSegment,
};
});
return {
@@ -163,6 +199,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
id: workspaceData.id,
appSetupCompleted: workspaceData.appSetupCompleted,
workspaceSettings: {
id: workspaceData.id,
recontactDays: workspaceData.recontactDays,
clickOutsideClose: workspaceData.clickOutsideClose,
overlay: workspaceData.overlay,
@@ -171,7 +208,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: resolveStorageUrlsInObject(workspaceData.styling),
},
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
// The runtime shape carries extra back-compat fields (placeholder
// segment, `hasFilters`, mirrored `environmentId`) that aren't part of
// the modern `TJsWorkspaceStateSurvey`. Cast through unknown — this is
// intentional and only this endpoint's response widens the type.
surveys: resolveStorageUrlsInObject(transformedSurveys) as unknown as TJsWorkspaceStateSurvey[],
actionClasses: workspaceData.actionClasses,
};
} catch (error) {
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
@@ -34,6 +35,10 @@ vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
validateResponseData: mocks.validateResponseData,
@@ -127,6 +132,7 @@ describe("putResponseHandler", () => {
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
});
test("returns a bad request response when the response id is missing", async () => {
@@ -8,6 +8,7 @@ import { THandlerParams } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -209,7 +210,7 @@ export const putResponseHandler = async ({
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { workspaceId, responseId } = params;
const { workspaceId: workspaceIdParam, responseId } = params;
if (!responseId) {
return {
@@ -217,6 +218,14 @@ export const putResponseHandler = async ({
};
}
const resolved = await resolveClientApiIds(workspaceIdParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", workspaceIdParam, true),
};
}
const { workspaceId } = resolved;
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
if ("response" in validatedUpdateInput) {
return validatedUpdateInput;
@@ -104,7 +104,11 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaClient = tx ?? prisma;
@@ -6,6 +6,7 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -56,11 +57,17 @@ export const POST = withV1ApiWrapper({
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await req.json();
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
@@ -211,7 +218,7 @@ export const POST = withV1ApiWrapper({
response: responseData,
});
if (responseInput.finished) {
if (responseInputData.finished) {
await sendToPipeline({
event: "responseFinished",
workspaceId,
@@ -51,7 +51,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
@@ -40,7 +40,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -37,7 +37,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -3,6 +3,7 @@ import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classe
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -84,8 +85,14 @@ export const PUT = withV1ApiWrapper({
let actionClassUpdate;
try {
actionClassUpdate = await req.json();
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -45,8 +46,14 @@ export const POST = withV1ApiWrapper({
try {
let actionClassInput;
try {
actionClassInput = await req.json();
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,6 +1,7 @@
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { TResponseData, ZResponseUpdateInput } from "@formbricks/types/responses";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -12,6 +13,11 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
type TUncheckedResponseUpdate = Record<string, unknown> & {
data: TResponseData;
language?: string;
};
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiV1Authentication | undefined,
@@ -120,10 +126,16 @@ export const PUT = withV1ApiWrapper({
auditLog.oldObject = result.response;
}
let responseUpdate;
let responseUpdate: TUncheckedResponseUpdate;
try {
responseUpdate = await req.json();
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -91,8 +92,14 @@ export const POST = withV1ApiWrapper({
try {
let jsonInput;
try {
jsonInput = await req.json();
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -19,8 +20,14 @@ export const POST = withV1ApiWrapper({
let storageInput;
try {
storageInput = await req.json();
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -9,6 +9,7 @@ import {
addLegacyProjectOverwrites,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -22,6 +23,12 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
type TSurveyUpdateBody = Record<string, unknown> & {
blocks?: Parameters<typeof validateSurveyInput>[0]["blocks"];
endings?: Parameters<typeof transformQuestionsToBlocks>[1];
questions?: Parameters<typeof transformQuestionsToBlocks>[0];
};
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
@@ -164,10 +171,16 @@ export const PUT = withV1ApiWrapper({
};
}
let surveyUpdate;
let surveyUpdate: TSurveyUpdateBody;
try {
surveyUpdate = await req.json();
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -188,7 +201,7 @@ export const PUT = withV1ApiWrapper({
if (hasQuestions) {
surveyUpdate.blocks = transformQuestionsToBlocks(
surveyUpdate.questions,
surveyUpdate.questions ?? [],
surveyUpdate.endings || result.survey.endings
);
surveyUpdate.questions = [];
@@ -208,7 +221,11 @@ export const PUT = withV1ApiWrapper({
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
const featureCheckResult = await checkFeaturePermissions(
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
organization,
result.survey
);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -51,7 +51,6 @@ const mockOrganization: TOrganization = {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithWorkspaceId["followUps"][number] = {
@@ -8,6 +8,7 @@ import {
addLegacyProjectOverwritesToList,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -84,8 +85,14 @@ export const POST = withV1ApiWrapper({
try {
let surveyInput;
try {
surveyInput = await req.json();
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
+9 -2
View File
@@ -2,6 +2,7 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -40,8 +41,14 @@ export const POST = withV1ApiWrapper({
let webhookInput;
try {
webhookInput = await req.json();
} catch {
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
@@ -21,24 +21,25 @@ vi.mock("react", async () => {
});
const contactId = "test-contact-id";
const workspaceId = "test-workspace-id";
describe("doesContactExist", () => {
describe("doesContactExistInWorkspace", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return true if contact exists", async () => {
test("should return true if contact exists in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -46,11 +47,11 @@ describe("doesContactExist", () => {
test("should return false if contact does not exist in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -1,15 +1,18 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
},
select: {
id: true,
},
});
export const doesContactExistInWorkspace = reactCache(
async (id: string, workspaceId: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
workspaceId,
},
select: {
id: true,
},
});
return !!contact;
});
return !!contact;
}
);
@@ -9,7 +9,7 @@ import {
} from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
@@ -30,7 +30,7 @@ vi.mock("@formbricks/database", () => ({
}));
vi.mock("./contact", () => ({
doesContactExist: vi.fn(),
doesContactExistInWorkspace: vi.fn(),
}));
const workspaceId = "workspace-id-mock";
@@ -81,13 +81,13 @@ describe("createDisplay", () => {
});
test("should create a display with contactId successfully", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -104,7 +104,7 @@ describe("createDisplay", () => {
const result = await createDisplay(displayInputWithoutContact);
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -115,13 +115,13 @@ describe("createDisplay", () => {
});
test("should create a display without contact if contact does not exist in the workspace", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -139,16 +139,16 @@ describe("createDisplay", () => {
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, workspaceId },
});
@@ -158,7 +158,7 @@ describe("createDisplay", () => {
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
@@ -171,7 +171,7 @@ describe("createDisplay", () => {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
@@ -179,15 +179,15 @@ describe("createDisplay", () => {
test("should throw original error on other errors during creation", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
});
test("should throw original error if doesContactExist fails", async () => {
test("should throw original error if doesContactExistInWorkspace fails", async () => {
const contactCheckError = new Error("Failed to check contact");
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
vi.mocked(doesContactExistInWorkspace).mockRejectedValue(contactCheckError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
expect(prisma.display.create).not.toHaveBeenCalled();
@@ -6,7 +6,7 @@ import {
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[workspaceId]/displays/types/display";
import { validateInputs } from "@/lib/utils/validate";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
const { contactId, surveyId, workspaceId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
const contactExists = contactId ? await doesContactExistInWorkspace(contactId, workspaceId) : false;
const survey = await prisma.survey.findUnique({
where: {
@@ -49,18 +49,7 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
return {
survey: {
@@ -84,8 +73,6 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
+11 -2
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -16,6 +17,7 @@ import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemPayloadTooLarge,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
@@ -170,8 +172,15 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
bodyData = await parseJsonBodyWithLimit(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
ok: false,
response: problemPayloadTooLarge(requestId, error.message, instance),
};
}
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
+11
View File
@@ -71,6 +71,17 @@ export function problemBadRequest(
});
}
export function problemPayloadTooLarge(
requestId: string,
detail: string = "Payload Too Large",
instance?: string
): Response {
return problemResponse(413, "Payload Too Large", detail, requestId, {
code: "payload_too_large",
instance,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "./request-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
@@ -1,8 +1,9 @@
import { z } from "zod";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body" | "payload_too_large";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
@@ -44,10 +45,18 @@ export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
let jsonInput: unknown;
try {
jsonInput = await request.json();
jsonInput = await parseJsonBodyWithLimit(request);
} catch (error) {
const details = { error: getErrorMessage(error) };
if (error instanceof RequestBodyTooLargeError) {
return {
details,
issue: "payload_too_large",
response: responses.payloadTooLargeResponse("Payload Too Large", details, true),
};
}
return {
details,
issue: "invalid_json",
+90
View File
@@ -0,0 +1,90 @@
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
export class RequestBodyTooLargeError extends Error {
readonly actualBytes: number | null;
readonly limitBytes: number;
constructor(limitBytes: number, actualBytes: number | null = null) {
super(`Request body must not exceed ${limitBytes} bytes`);
this.name = "RequestBodyTooLargeError";
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}
const textDecoder = new TextDecoder();
const getContentLength = (headers: Headers): number | null => {
const contentLength = headers.get("content-length");
if (!contentLength) {
return null;
}
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength < 0) {
return null;
}
return parsedContentLength;
};
const assertBodySize = (actualBytes: number, limitBytes: number): void => {
if (actualBytes > limitBytes) {
throw new RequestBodyTooLargeError(limitBytes, actualBytes);
}
};
export const readRequestBodyWithLimit = async (
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<string> => {
const contentLength = getContentLength(request.headers);
if (contentLength !== null) {
assertBodySize(contentLength, limitBytes);
}
if (!request.body) {
return "";
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > limitBytes) {
await reader.cancel().catch(() => undefined);
throw new RequestBodyTooLargeError(limitBytes, receivedBytes);
}
chunks.push(value);
}
if (chunks.length === 0) {
return "";
}
if (chunks.length === 1) {
return textDecoder.decode(chunks[0]);
}
const body = new Uint8Array(receivedBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return textDecoder.decode(body);
};
export const parseJsonBodyWithLimit = async <TJson = unknown>(
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<TJson> => JSON.parse(await readRequestBodyWithLimit(request, limitBytes)) as TJson;
+27 -1
View File
@@ -17,7 +17,8 @@ interface ApiErrorResponse {
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict";
| "conflict"
| "payload_too_large";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -80,6 +81,30 @@ const badRequestResponse = (
);
};
const payloadTooLargeResponse = (
message: string = "Payload Too Large",
details: ApiErrorResponse["details"] = {},
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "payload_too_large",
message,
details,
},
{
status: 413,
headers,
}
);
};
const methodNotAllowedResponse = (
res: CustomNextApiResponse,
allowedMethods: string[],
@@ -294,6 +319,7 @@ export const responses = {
unauthorizedResponse,
notFoundResponse,
successResponse,
payloadTooLargeResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
+2
View File
@@ -1859,6 +1859,7 @@ checksums:
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
@@ -1893,6 +1894,7 @@ checksums:
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
+11 -29
View File
@@ -3,7 +3,7 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
} from "./service";
@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({
generateText: vi.fn(),
isAiConfigured: vi.fn(),
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
loggerError: vi.fn(),
}));
@@ -62,7 +61,6 @@ vi.mock("@/lib/organization/service", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
@@ -74,10 +72,8 @@ describe("AI organization service", () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
});
test("returns the instance AI status and organization settings", async () => {
@@ -88,9 +84,7 @@ describe("AI organization service", () => {
expect(result).toMatchObject({
organizationId: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
isAISmartToolsEntitled: true,
isAIDataAnalysisEntitled: true,
isInstanceConfigured: true,
});
});
@@ -104,29 +98,22 @@ describe("AI organization service", () => {
test("fails closed when the organization is not entitled to AI", async () => {
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("fails closed when the requested AI capability is disabled", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: "org_1",
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: true,
});
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("fails closed when the instance AI configuration is incomplete", async () => {
mocks.isAiConfigured.mockReturnValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("generates organization AI text with the configured package abstraction", async () => {
@@ -135,7 +122,6 @@ describe("AI organization service", () => {
const result = await generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
});
@@ -159,14 +145,12 @@ describe("AI organization service", () => {
await expect(
generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
})
).rejects.toThrow(modelError);
expect(mocks.loggerError).toHaveBeenCalledWith(
{
organizationId: "org_1",
capability: "smartTools",
isInstanceConfigured: true,
errorCode: undefined,
err: modelError,
@@ -175,34 +159,32 @@ describe("AI organization service", () => {
);
});
describe("getAIDataAnalysisUnavailableReason", () => {
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(getAIDataAnalysisUnavailableReason(baseConfig)).toBeUndefined();
expect(getAISmartToolsUnavailableReason(baseConfig)).toBeUndefined();
});
test("returns not_in_plan when not entitled", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEntitled: false })).toBe(
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 disabled at org level", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEnabled: false })).toBe(
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(getAIDataAnalysisUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
"instance_not_configured"
);
});
+9 -27
View File
@@ -4,12 +4,11 @@ import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export const AI_ERROR_CODES = {
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
} as const;
@@ -18,9 +17,7 @@ export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
isAISmartToolsEntitled: boolean;
isAIDataAnalysisEntitled: boolean;
isInstanceConfigured: boolean;
}
@@ -33,52 +30,40 @@ export const getOrganizationAIConfig = async (organizationId: string): Promise<T
throw new ResourceNotFoundError("Organization", organizationId);
}
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
getIsAISmartToolsEnabled(organizationId),
getIsAIDataAnalysisEnabled(organizationId),
]);
const isAISmartToolsEntitled = await getIsAISmartToolsEnabled(organizationId);
return {
organizationId,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
isAISmartToolsEntitled,
isAIDataAnalysisEntitled,
isInstanceConfigured: isInstanceAIConfigured(),
};
};
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export const getAIDataAnalysisUnavailableReason = (
export const getAISmartToolsUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
if (!aiConfig.isAIDataAnalysisEntitled) return "not_in_plan";
if (!aiConfig.isAIDataAnalysisEnabled) return "not_enabled";
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"
organizationId: string
): Promise<TOrganizationAIConfig> => {
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!isCapabilityEntitled) {
if (!aiConfig.isAISmartToolsEntitled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
}
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
if (!aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
}
@@ -88,15 +73,13 @@ export const assertOrganizationAIConfigured = async (
type TGenerateOrganizationAITextInput = {
organizationId: string;
capability: "smartTools" | "dataAnalysis";
} & Parameters<typeof generateText>[0];
export const generateOrganizationAIText = async ({
organizationId,
capability,
...options
}: TGenerateOrganizationAITextInput): Promise<Awaited<ReturnType<typeof generateText>>> => {
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
const aiConfig = await assertOrganizationAIConfigured(organizationId);
try {
return await generateText(options, env);
@@ -104,7 +87,6 @@ export const generateOrganizationAIText = async ({
logger.error(
{
organizationId,
capability,
isInstanceConfigured: aiConfig.isInstanceConfigured,
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
err: error,
-1
View File
@@ -38,7 +38,6 @@ describe("auth", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+26 -5
View File
@@ -46,6 +46,13 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/hub/service", () => ({
deleteHubTenantData: vi.fn().mockResolvedValue({
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
error: null,
}),
}));
describe("Organization Service", () => {
beforeEach(() => {
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
@@ -73,7 +80,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -126,7 +132,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -179,7 +184,6 @@ describe("Organization Service", () => {
updatedAt: new Date(),
billing: expectedBilling,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -239,7 +243,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
workspaces: [
@@ -281,7 +284,6 @@ describe("Organization Service", () => {
usageCycleAnchor: expect.any(Date),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
@@ -355,6 +357,7 @@ describe("Organization Service", () => {
billing: { stripeCustomerId: "cus_123" },
memberships: [],
workspaces: [],
feedbackDirectories: [],
} as any);
await deleteOrganization("org1");
@@ -363,5 +366,23 @@ describe("Organization Service", () => {
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
}
});
test("should purge Hub-owned data for each feedback directory", async () => {
const { deleteHubTenantData } = await import("@/modules/hub/service");
vi.mocked(prisma.organization.delete).mockResolvedValue({
id: "org1",
name: "Test Org",
billing: null,
memberships: [],
workspaces: [],
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
} as any);
await deleteOrganization("org1");
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
});
+13 -2
View File
@@ -19,6 +19,7 @@ import { updateUser } from "@/lib/user/service";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getWorkspaces } from "@/lib/workspace/service";
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
import { deleteHubTenantData } from "@/modules/hub/service";
import { validateInputs } from "../utils/validate";
export const select = {
@@ -35,7 +36,6 @@ export const select = {
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -74,7 +74,6 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
@@ -294,6 +293,11 @@ export const deleteOrganization = async (organizationId: string) => {
id: true,
},
},
feedbackDirectories: {
select: {
id: true,
},
},
},
});
@@ -301,6 +305,13 @@ export const deleteOrganization = async (organizationId: string) => {
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
await cleanupStripeCustomer(stripeCustomerId);
}
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) for each
// directory tenant. Failures are logged inside the gateway and do not roll back the
// local delete.
for (const directory of deletedOrganization.feedbackDirectories) {
await deleteHubTenantData(directory.id);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -228,7 +228,6 @@ export const mockOrganizationOutput: TOrganization = {
createdAt: currentDate,
updatedAt: currentDate,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
limits: {
-2
View File
@@ -67,7 +67,6 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
{
id: "org2",
@@ -85,7 +84,6 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
@@ -18,6 +18,7 @@ import {
ValidationError,
isExpectedError,
} from "@formbricks/types/errors";
import { RequestBodyTooLargeError } from "@/app/lib/api/request-body";
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
@@ -78,6 +79,7 @@ describe("isExpectedError (shared helper)", () => {
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
"RequestBodyTooLargeError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -97,6 +99,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
{ ErrorClass: RequestBodyTooLargeError, args: [2 * 1024 * 1024] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
+26 -2
View File
@@ -2,11 +2,30 @@ import { AsyncParser } from "@json2csv/node";
import * as xlsx from "xlsx";
import { logger } from "@formbricks/logger";
// Defang spreadsheet formula injection. Cell values starting with
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
// Sanitize at the render boundary only — never rewrite row keys, since
// distinct user-controlled labels could collide after prefixing (e.g.
// "=field" and "'=field" both map to "'=field"), dropping cell data.
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
const sanitizeFormulaInjection = <T>(value: T): T => {
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
return `'${value}` as T;
}
return value;
};
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
let csv: string = "";
// Field descriptors preserve the original lookup key while overriding the
// rendered label and cell value with sanitized versions.
const parser = new AsyncParser({
fields,
fields: fields.map((name) => ({
label: sanitizeFormulaInjection(name),
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
})),
});
try {
@@ -23,8 +42,13 @@ export const convertToXlsxBuffer = (
fields: string[],
jsonData: Record<string, string | number>[]
): Buffer => {
// Build as array-of-arrays so original row keys are looked up before
// sanitization is applied to the rendered header/cell only.
const headerRow = fields.map(sanitizeFormulaInjection);
const dataRows = jsonData.map((row) => fields.map((name) => sanitizeFormulaInjection(row[name])));
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",
"attribute_key_required": "Schlüssel ist erforderlich",
"attribute_key_reserved_future_default": "Der Schlüssel ist für zukünftige Standardattribute reserviert ({reservedKeys}). Bitte wähle einen anderen Schlüssel.",
"attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen",
"attribute_label": "Bezeichnung",
"attribute_label_placeholder": "z. B. Geburtsdatum",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Persönlichen Link erstellen",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu erstellen.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_csv_reserved_column_names": "Reservierte CSV-Spaltennamen: {columns}. Diese Namen sind für zukünftige Standardattribute ({reservedKeys}) reserviert und können nicht als neue Attribute erstellt werden.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_activity_yet": "Noch keine Aktivität",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
},
"general": {
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
"ai_data_analysis_enabled_description": "KI nutzen, um mehr aus deinen Daten herauszuholen richte Dashboards, Diagramme, Berichte und mehr ein. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks KI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte deine:n Administrator:in, AI_PROVIDER, AI_MODEL und die passenden Provider-Zugangsdaten zu setzen, bevor du KI-Funktionen aktivierst.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "„Umfrage geschlossen“-Nachricht anpassen",
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_theme_in_look_and_feel_settings": "Passe das Theme in den <lookFeelLink>Look & Feel</lookFeelLink> Einstellungen an.",
"ai_data_analysis_disabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_features_not_enabled": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
"attribute_key_required": "Key is required",
"attribute_key_reserved_future_default": "Key is reserved for future default attributes ({reservedKeys}). Please choose a different key.",
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
"attribute_label": "Label",
"attribute_label_placeholder": "e.g. Date of Birth",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
"invalid_csv_reserved_column_names": "Reserved CSV column name(s): {columns}. These names are reserved for future default attributes ({reservedKeys}) and cannot be created as new attributes.",
"invalid_date_format": "Invalid date format. Please use a valid date.",
"invalid_number_format": "Invalid number format. Please enter a valid number.",
"no_activity_yet": "No activity yet",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Workspaces being granted access"
},
"general": {
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Manage AI-powered features for this organization.",
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_theme_in_look_and_feel_settings": "Adjust the theme in the <lookFeelLink>Look & Feel</lookFeelLink> Settings.",
"ai_data_analysis_disabled": "AI data analysis is disabled for this organization.",
"ai_features_not_enabled": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",
"attribute_key_required": "La clave es obligatoria",
"attribute_key_reserved_future_default": "La clave está reservada para atributos predeterminados futuros ({reservedKeys}). Por favor, elige una clave diferente.",
"attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generar enlace personal",
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
"invalid_csv_reserved_column_names": "Nombre(s) de columna CSV reservado(s): {columns}. Estos nombres están reservados para atributos predeterminados futuros ({reservedKeys}) y no se pueden crear como nuevos atributos.",
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
"no_activity_yet": "Aún no hay actividad",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
"ai_data_analysis_disabled": "El análisis de datos con IA está deshabilitado para esta organización.",
"ai_features_not_enabled": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",
"attribute_key_required": "La clé est requise",
"attribute_key_reserved_future_default": "La clé est réservée pour les attributs par défaut futurs ({reservedKeys}). Veuillez choisir une clé différente.",
"attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr: uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre",
"attribute_label": "Étiquette",
"attribute_label_placeholder": "ex. Date de naissance",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
"invalid_csv_reserved_column_names": "Nom(s) de colonne CSV réservé(s) : {columns}. Ces noms sont réservés pour les attributs par défaut futurs ({reservedKeys}) et ne peuvent pas être créés en tant que nouveaux attributs.",
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
"no_activity_yet": "Aucune activité pour le moment",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Espaces de travail en cours d'ajout"
},
"general": {
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
"ai_enabled": "IA Formbricks",
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
"ai_instance_not_configured": "L'IA est configurée au niveau de l'instance via des variables d'environnement. Demandez à votre administrateur de définir AI_PROVIDER, les identifiants du fournisseur et la liste de modèles correspondante avant d'activer les fonctionnalités d'IA.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
"adjust_theme_in_look_and_feel_settings": "Ajuste le thème dans les paramètres <lookFeelLink>Apparence et ressenti</lookFeelLink>.",
"ai_data_analysis_disabled": "L'analyse de données par IA est désactivée pour cette organisation.",
"ai_features_not_enabled": "Les fonctionnalités IA ne sont pas activées pour cette organisation.",
"ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.",
"ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók. Betűvel kell kezdődnie.",
"attribute_key_placeholder": "például: szuletesi_ido",
"attribute_key_required": "A kulcs kötelező",
"attribute_key_reserved_future_default": "A kulcs le van foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}). Kérem, válasszon egy másik kulcsot.",
"attribute_key_safe_identifier_required": "A kulcs csak biztonságos azonosító lehet: csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók, és betűvel kell kezdődnie",
"attribute_label": "Címke",
"attribute_label_placeholder": "például: Születési idő",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Személyes hivatkozás előállítása",
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
"invalid_csv_reserved_column_names": "Fenntartott CSV oszlopnév/nevek: {columns}. Ezek a nevek le vannak foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}), és nem hozhatók létre új attribútumokként.",
"invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.",
"no_activity_yet": "Még nincs tevékenység",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
},
"general": {
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
"ai_data_analysis_disabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
"ai_features_not_enabled": "Az AI funkciók nincsenek engedélyezve ezen szervezet számára.",
"ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.",
"ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth",
"attribute_key_required": "キーは必須です",
"attribute_key_reserved_future_default": "このキーは将来のデフォルト属性用に予約されています({reservedKeys})。別のキーを選択してください。",
"attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります",
"attribute_label": "ラベル",
"attribute_label_placeholder": "例: 生年月日",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
"invalid_csv_reserved_column_names": "予約されたCSV列名: {columns}。これらの名前は将来のデフォルト属性({reservedKeys})用に予約されており、新しい属性として作成できません。",
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
"no_activity_yet": "まだアクティビティがありません",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "アクセス権が付与されるワークスペース"
},
"general": {
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "この組織のAI機能を管理します。",
"ai_instance_not_configured": "AI は環境変数を使ってインスタンスレベルで設定されます。AI 機能を有効にする前に、管理者に AI_PROVIDER、このプロバイダーの認証情報、および対応するモデル一覧を設定してもらってください。",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "「フォームはクローズしました」メッセージを調整",
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
"ai_data_analysis_disabled": "この組織ではAIデータ分析が無効になっています。",
"ai_features_not_enabled": "この組織ではAI機能が有効になっていません。",
"ai_instance_not_configured": "AIが設定されていません。管理者にお問い合わせください。",
"ai_smart_tools_disabled": "この組織ではAIスマートツールが無効になっています。",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",
"attribute_key_required": "Sleutel is verplicht",
"attribute_key_reserved_future_default": "Sleutel is gereserveerd voor toekomstige standaardattributen ({reservedKeys}). Kies een andere sleutel.",
"attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter",
"attribute_label": "Label",
"attribute_label_placeholder": "bijv. Geboortedatum",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Persoonlijke link genereren",
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
"invalid_csv_reserved_column_names": "Gereserveerde CSV-kolomnaam/namen: {columns}. Deze namen zijn gereserveerd voor toekomstige standaardattributen ({reservedKeys}) en kunnen niet als nieuwe attributen worden aangemaakt.",
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
"no_activity_yet": "Nog geen activiteit",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Werkruimtes die toegang krijgen"
},
"general": {
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
"ai_instance_not_configured": "AI wordt op instantieniveau geconfigureerd via omgevingsvariabelen. Vraag je beheerder om AI_PROVIDER, de inloggegevens voor die provider en de bijbehorende modellenlijst in te stellen voordat AI-functies worden ingeschakeld.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Pas het bericht 'Enquête gesloten' aan",
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
"ai_data_analysis_disabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
"ai_features_not_enabled": "AI-functies zijn niet ingeschakeld voor deze organisatie.",
"ai_instance_not_configured": "AI is niet geconfigureerd. Neem contact op met je beheerder.",
"ai_smart_tools_disabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolha uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex: Data de nascimento",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Esses nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
"no_activity_yet": "Nenhuma atividade ainda",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Workspaces recebendo acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
"ai_instance_not_configured": "A IA é configurada no nível da instância por meio de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse provedor e a lista de modelos correspondente antes de habilitar os recursos de IA.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Ajustar mensagem 'Pesquisa Encerrada''",
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
"adjust_theme_in_look_and_feel_settings": "Ajuste o tema nas configurações de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desabilitada para esta organização.",
"ai_features_not_enabled": "Os recursos de IA não estão habilitados para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Entre em contato com seu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolhe uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex. Data de nascimento",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Estes nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
"no_activity_yet": "Ainda sem atividade",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Workspaces a receber acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
"ai_enabled": "IA da Formbricks",
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
"ai_instance_not_configured": "A IA é configurada ao nível da instância através de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse fornecedor e a lista de modelos correspondente antes de ativar as funcionalidades de IA.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Ajustar mensagem de 'Inquérito Fechado'",
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
"adjust_theme_in_look_and_feel_settings": "Ajusta o tema nas definições de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desativada para esta organização.",
"ai_features_not_enabled": "As funcionalidades de IA não estão ativadas para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Contacta o teu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",
"attribute_key_required": "Cheia este obligatorie",
"attribute_key_reserved_future_default": "Cheia este rezervată pentru atribute implicite viitoare ({reservedKeys}). Te rugăm să alegi o cheie diferită.",
"attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă",
"attribute_label": "Etichetă",
"attribute_label_placeholder": "ex: Data nașterii",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generează link personal",
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
"invalid_csv_reserved_column_names": "Nume de coloană CSV rezervate: {columns}. Aceste nume sunt rezervate pentru atribute implicite viitoare ({reservedKeys}) și nu pot fi create ca atribute noi.",
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
"no_activity_yet": "Nicio activitate încă",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
},
"general": {
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
"ai_instance_not_configured": "AI este configurată la nivel de instanță prin variabile de mediu. Cere administratorului să configureze AI_PROVIDER, credențialele acelui furnizor și lista de modele corespunzătoare înainte de a activa funcționalitățile AI.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'",
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
"adjust_theme_in_look_and_feel_settings": "Ajustează tema în setările <lookFeelLink>Aspect și Experiență</lookFeelLink>.",
"ai_data_analysis_disabled": "Analiza de date AI este dezactivată pentru această organizație.",
"ai_features_not_enabled": "Funcțiile AI nu sunt activate pentru această organizație.",
"ai_instance_not_configured": "AI nu este configurat. Contactează administratorul.",
"ai_smart_tools_disabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
+3 -4
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth",
"attribute_key_required": "Ключ обязателен",
"attribute_key_reserved_future_default": "Ключ зарезервирован для будущих атрибутов по умолчанию ({reservedKeys}). Пожалуйста, выбери другой ключ.",
"attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы",
"attribute_label": "Метка",
"attribute_label_placeholder": "например, дата рождения",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Сгенерировать персональную ссылку",
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
"invalid_csv_reserved_column_names": "Зарезервированные названия столбцов CSV: {columns}. Эти названия зарезервированы для будущих атрибутов по умолчанию ({reservedKeys}) и не могут быть созданы как новые атрибуты.",
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
"no_activity_yet": "Пока нет активности",
@@ -2429,7 +2431,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": "в месяц",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
},
"general": {
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
"ai_instance_not_configured": "ИИ настраивается на уровне инстанса через переменные окружения. Попросите администратора настроить AI_PROVIDER, учетные данные этого провайдера и соответствующий список моделей перед включением функций ИИ.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Изменить сообщение «Опрос закрыт»",
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
"ai_data_analysis_disabled": "Анализ данных с помощью ИИ отключён для этой организации.",
"ai_features_not_enabled": "Функции ИИ не включены для этой организации.",
"ai_instance_not_configured": "ИИ не настроен. Свяжись с администратором.",
"ai_smart_tools_disabled": "Умные инструменты ИИ отключены для этой организации.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",
"attribute_key_required": "Nyckel krävs",
"attribute_key_reserved_future_default": "Nyckeln är reserverad för framtida standardattribut ({reservedKeys}). Välj en annan nyckel.",
"attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav",
"attribute_label": "Etikett",
"attribute_label_placeholder": "t.ex. Födelsedatum",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Generera personlig länk",
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
"invalid_csv_reserved_column_names": "Reserverade CSV-kolumnnamn: {columns}. Dessa namn är reserverade för framtida standardattribut ({reservedKeys}) och kan inte skapas som nya attribut.",
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
"no_activity_yet": "Ingen aktivitet än",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
},
"general": {
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
"ai_instance_not_configured": "AI konfigureras på instansnivå via miljövariabler. Be din administratör att ange AI_PROVIDER, autentiseringsuppgifterna för den leverantören och den tillhörande modellistan innan AI-funktioner aktiveras.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "Justera meddelande för 'Enkät stängd'",
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
"ai_data_analysis_disabled": "AI-dataanalys är inaktiverad för den här organisationen.",
"ai_features_not_enabled": "AI-funktioner är inte aktiverade för den här organisationen.",
"ai_instance_not_configured": "AI är inte konfigurerad. Kontakta din administratör.",
"ai_smart_tools_disabled": "AI smarta verktyg är inaktiverade för den här organisationen.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "Yalnızca küçük harfler, rakamlar ve alt çizgiler. Bir harfle başlamalıdır.",
"attribute_key_placeholder": "örn. dogum_tarihi",
"attribute_key_required": "Anahtar gereklidir",
"attribute_key_reserved_future_default": "Anahtar, gelecekteki varsayılan özellikler için ayrılmıştır ({reservedKeys}). Lütfen farklı bir anahtar seçin.",
"attribute_key_safe_identifier_required": "Anahtar güvenli bir tanımlayıcı olmalıdır: yalnızca küçük harfler, rakamlar ve alt çizgiler içermeli ve bir harfle başlamalıdır",
"attribute_label": "Etiket",
"attribute_label_placeholder": "örn. Doğum Tarihi",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "Kişisel Bağlantı Oluştur",
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir anket seç.",
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harf, rakam ve alt çizgi içerebilir ve bir harfle başlamalıdır.",
"invalid_csv_reserved_column_names": "Ayrılmış CSV sütun adı/adları: {columns}. Bu adlar gelecekteki varsayılan özellikler ({reservedKeys}) için ayrılmıştır ve yeni özellik olarak oluşturulamaz.",
"invalid_date_format": "Geçersiz tarih formatı. Lütfen geçerli bir tarih kullanın.",
"invalid_number_format": "Geçersiz sayı formatı. Lütfen geçerli bir sayı girin.",
"no_activity_yet": "Henüz aktivite yok",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "Erişim verilen çalışma alanları"
},
"general": {
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
"ai_data_analysis_enabled_description": "Verilerinden daha fazlasını elde etmek, kontrol panelleri, grafikler, raporlar ve daha fazlasını kurmak için yapay zeka. Deneyim verilerine dokunur.",
"ai_enabled": "Formbricks Yapay Zeka",
"ai_enabled_description": "Bu organizasyon için yapay zeka destekli özellikleri yönet.",
"ai_instance_not_configured": "Yapay zeka, ortam değişkenleri aracılığıyla instance seviyesinde yapılandırılır. Yapay zeka özelliklerini etkinleştirmeden önce yöneticinden AI_PROVIDER, AI_MODEL ve eşleşen sağlayıcı kimlik bilgilerini ayarlamasını iste.",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "\"Anket Kapatıldı\" mesajını düzenle",
"adjust_survey_closed_message_description": "Anket kapalıyken ziyaretçilerin gördüğü mesajı değiştir.",
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
"ai_data_analysis_disabled": "Bu organizasyon için yapay zeka veri analizi devre dışı.",
"ai_features_not_enabled": "Bu organizasyon için yapay zeka özellikleri etkinleştirilmemiş.",
"ai_instance_not_configured": "Yapay zeka yapılandırılmamış. Yöneticinle iletişime geç.",
"ai_smart_tools_disabled": "Bu organizasyon için yapay zeka akıllı araçları devre dışı.",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "键为必填项",
"attribute_key_reserved_future_default": "该键已保留用于未来的默认属性({reservedKeys})。请选择其他键。",
"attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头",
"attribute_label": "标签",
"attribute_label_placeholder": "例如:出生日期",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
"invalid_csv_reserved_column_names": "CSV 列名已被保留:{columns}。这些名称已保留用于未来的默认属性({reservedKeys}),无法创建为新属性。",
"invalid_date_format": "日期格式无效。请使用有效日期。",
"invalid_number_format": "数字格式无效。请输入有效的数字。",
"no_activity_yet": "暂无活动",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "将被授权访问的工作区"
},
"general": {
"ai_data_analysis_enabled": "数据增强与分析(AI",
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
"ai_instance_not_configured": "AI 通过环境变量在实例级别进行配置。启用 AI 功能前,请让管理员设置 AI_PROVIDER、该提供商的凭据以及对应的模型列表。",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "调整 \"调查 关闭\" 消息",
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
"ai_data_analysis_disabled": "此组织已禁用 AI 数据分析。",
"ai_features_not_enabled": "此组织未启用 AI 功能。",
"ai_instance_not_configured": "AI 未配置。请联系您的管理员。",
"ai_smart_tools_disabled": "此组织已禁用 AI 智能工具。",
+2 -3
View File
@@ -1935,6 +1935,7 @@
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "金鑰為必填項目",
"attribute_key_reserved_future_default": "此鍵已保留供未來預設屬性使用({reservedKeys})。請選擇其他鍵。",
"attribute_key_safe_identifier_required": "金鑰必須為安全識別字:僅限小寫字母、數字和底線,且必須以字母開頭",
"attribute_label": "標籤",
"attribute_label_placeholder": "例如:出生日期",
@@ -1969,6 +1970,7 @@
"generate_personal_link": "產生個人連結",
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
"invalid_csv_reserved_column_names": "保留的 CSV 欄位名稱:{columns}。這些名稱已保留供未來預設屬性使用({reservedKeys}),無法建立為新屬性。",
"invalid_date_format": "日期格式無效。請使用有效的日期。",
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
"no_activity_yet": "尚無活動",
@@ -2610,8 +2612,6 @@
"workspaces_being_added": "正在授予存取權限的工作區"
},
"general": {
"ai_data_analysis_enabled": "資料增強與分析(AI",
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理此組織的 AI 功能。",
"ai_instance_not_configured": "AI 會透過環境變數在實例層級進行設定。啟用 AI 功能前,請管理員設定 AI_PROVIDER、該供應商的憑證,以及對應的模型清單。",
@@ -2818,7 +2818,6 @@
"adjust_survey_closed_message": "調整「問卷已關閉」訊息",
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外觀與感覺</lookFeelLink>設定中調整主題。",
"ai_data_analysis_disabled": "此組織已停用 AI 資料分析。",
"ai_features_not_enabled": "此組織未啟用 AI 功能。",
"ai_instance_not_configured": "AI 未設定。請聯絡您的管理員。",
"ai_smart_tools_disabled": "此組織已停用 AI 智慧工具。",
+15 -2
View File
@@ -1,6 +1,7 @@
import { ZodRawShape, z } from "zod";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
@@ -73,10 +74,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
let bodyData;
let bodyData: Record<string, unknown>;
try {
bodyData = await request.json();
bodyData = await parseJsonBodyWithLimit<Record<string, unknown>>(request);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return handleApiError(request, {
type: "payload_too_large",
details: [
{
field: "body",
issue: error.message,
},
],
});
}
logger.error({ error, url: request.url }, "Error parsing JSON input");
return handleApiError(request, {
type: "bad_request",
@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok } from "@formbricks/types/error-handlers";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { handleApiError } from "@/modules/api/v2/lib/utils";
+30
View File
@@ -148,6 +148,35 @@ const conflictResponse = ({
);
};
const payloadTooLargeResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 413,
message: "Payload Too Large",
details,
},
},
{
status: 413,
headers,
}
);
};
const unprocessableEntityResponse = ({
details = [],
cors = false,
@@ -351,6 +380,7 @@ export const responses = {
forbiddenResponse,
notFoundResponse,
conflictResponse,
payloadTooLargeResponse,
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
+2
View File
@@ -28,6 +28,8 @@ export const handleApiError = (
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse({ details: err.details });
case "payload_too_large":
return responses.payloadTooLargeResponse({ details: err.details });
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":
@@ -10,6 +10,10 @@ import {
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceIds: string[], params: TGetContactAttributeKeysFilter) => {
@@ -45,6 +49,13 @@ export const createContactAttributeKey = async (
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { workspaceId, name, description, key, dataType } = contactAttributeKey;
if (isReservedFutureDefaultAttributeKey(key)) {
return err({
type: "bad_request",
details: [{ field: "key", issue: getReservedFutureDefaultAttributeKeyIssue([key]) }],
});
}
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
workspace: {
@@ -105,6 +105,28 @@ describe("createContactAttributeKey", () => {
}
});
test("returns bad request when key is reserved for future defaults", async () => {
const result = await createContactAttributeKey({
...inputContactAttributeKey,
key: "user_id",
});
expect(result.ok).toBe(false);
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "bad_request",
details: [
{
field: "key",
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
],
});
}
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
@@ -2,6 +2,10 @@ import { z } from "zod";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({})
.refine(
@@ -38,6 +42,14 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyInput",
@@ -65,6 +77,14 @@ export const ZContactAttributeKeyCreateInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyCreateInput",
+7 -1
View File
@@ -10,7 +10,13 @@ export type ApiErrorDetails = {
export type ApiErrorResponseV2 =
| {
type: "unauthorized" | "forbidden" | "conflict" | "too_many_requests" | "internal_server_error";
type:
| "unauthorized"
| "forbidden"
| "conflict"
| "payload_too_large"
| "too_many_requests"
| "internal_server_error";
details?: ApiErrorDetails;
}
| {
+36 -1
View File
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
import { createUser, getUser, getUserByEmail, updateUser, updateUserLastLoginAt } from "./user";
@@ -53,6 +53,41 @@ describe("User Management", () => {
expect(result).toEqual(mockPrismaUser);
});
test("creates a user with an Azure AD enterprise display name", async () => {
const enterpriseDisplayName = "Lastname,Firstname (DEPT) COMPANY-CITY";
vi.mocked(prisma.user.create).mockResolvedValueOnce({
...mockPrismaUser,
name: enterpriseDisplayName,
});
const result = await createUser({
email: mockUser.email,
name: enterpriseDisplayName,
locale: mockUser.locale,
});
expect(result.name).toBe(enterpriseDisplayName);
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: enterpriseDisplayName,
}),
})
);
});
test("rejects display names with newline characters", async () => {
await expect(
createUser({
email: mockUser.email,
name: "Lastname,Firstname\n(DEPT) COMPANY-CITY",
locale: mockUser.locale,
})
).rejects.toThrow(ValidationError);
expect(prisma.user.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError when email already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
@@ -10,7 +10,6 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
SPAM_PROTECTION: "spam-protection",
CONTACTS: "contacts",
AI_SMART_TOOLS: "ai-smart-tools",
AI_DATA_ANALYSIS: "ai-data-analysis",
FEEDBACK_DIRECTORIES: "feedback-directories",
DASHBOARDS: "dashboards",
} as const;
@@ -81,7 +81,7 @@ export const translateSurveyFieldsAction = authenticatedActionClient
],
});
await assertOrganizationAIConfigured(organizationId, "smartTools");
await assertOrganizationAIConfigured(organizationId);
const translations = await translateFields({
organizationId,
@@ -40,7 +40,6 @@ Rules:
const result = await generateOrganizationAIText({
organizationId,
capability: "smartTools",
system: systemPrompt,
prompt: JSON.stringify(items),
});
@@ -3,6 +3,7 @@ import cubejs, { type Query } from "@cubejs-client/core";
import { randomUUID } from "node:crypto";
import { logger } from "@formbricks/logger";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "@/modules/ee/analysis/lib/date-presets";
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { type TCubeQuerySource, getCubeApiConfig } from "./cube-config";
@@ -89,7 +90,7 @@ export async function executeTenantScopedQuery(input: TScopedCubeQueryInput) {
try {
const client = cubejs(token, { apiUrl });
const resultSet = await client.load(input.query as Query);
const resultSet = await client.load(expandPresetDateRanges(input.query) as Query);
const result = resultSet.tablePivot();
queueCubeQueryAuditEvent({ input, requestId, status: "success" });
return result;
@@ -363,8 +363,7 @@ export const generateAIChartAction = authenticatedActionClient
await checkDashboardsEnabled(organizationId);
// Verify AI is entitled, enabled at org level, and configured at instance level
await assertOrganizationAIConfigured(organizationId, "dataAnalysis");
await assertOrganizationAIConfigured(organizationId);
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
@@ -1,5 +1,5 @@
import { use } from "react";
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -87,7 +87,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
getConnectorsWithMappings(workspaceId),
getOrganizationAIConfig(organization.id),
]);
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
const isAIAvailable = !aiUnavailableReason;
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
@@ -83,6 +83,24 @@ export function TimeDimensionPanel({
}
};
const handleDateRangeTypeChange = (value: "preset" | "custom") => {
setDateRangeType(value);
if (!timeDimension) return;
if (value === "preset") {
const nextPreset = presetValue || "last 30 days";
if (!presetValue) setPresetValue(nextPreset);
onTimeDimensionChange({ ...timeDimension, dateRange: nextPreset });
return;
}
const start = customStartDate ?? new Date();
const end = customEndDate ?? start;
if (!customStartDate) setCustomStartDate(start);
if (!customEndDate) setCustomEndDate(end);
onTimeDimensionChange({ ...timeDimension, dateRange: [start, end] });
};
if (!timeDimension) {
return (
<div className="space-y-2">
@@ -150,7 +168,7 @@ export function TimeDimensionPanel({
<div className="space-y-2">
<Select
value={dateRangeType}
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
onValueChange={(value) => handleDateRangeTypeChange(value as "preset" | "custom")}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import type { TChartQuery } from "@formbricks/types/analysis";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { executeTenantScopedQuery } from "@/modules/ee/analysis/api/lib/cube-client";
@@ -99,7 +99,7 @@ export async function DashboardDetailPage({
getFeedbackDirectoriesByWorkspaceId(workspaceId),
getOrganizationAIConfig(organization.id),
]);
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
const isAIAvailable = !aiUnavailableReason;
let dashboard;
@@ -0,0 +1,37 @@
import { addDays, formatDate, startOfDay, startOfMonth, startOfQuarter, startOfYear } from "date-fns";
import type { TChartQuery } from "@formbricks/types/analysis";
// Cube's native "last N days" / "this month" / etc. strings exclude today; we expand them
// to explicit inclusive ranges so charts behave like every other analytics tool (GA, Mixpanel,
// PostHog, ...) and include the current partial day.
const PRESET_RESOLVERS: Record<string, (now: Date) => [Date, Date]> = {
today: (now) => [startOfDay(now), startOfDay(now)],
yesterday: (now) => [addDays(startOfDay(now), -1), addDays(startOfDay(now), -1)],
"last 7 days": (now) => [addDays(startOfDay(now), -6), startOfDay(now)],
"last 30 days": (now) => [addDays(startOfDay(now), -29), startOfDay(now)],
"this month": (now) => [startOfMonth(now), startOfDay(now)],
"last month": (now) => {
const firstOfThisMonth = startOfMonth(now);
const lastOfLastMonth = addDays(firstOfThisMonth, -1);
return [startOfMonth(lastOfLastMonth), lastOfLastMonth];
},
"this quarter": (now) => [startOfQuarter(now), startOfDay(now)],
"this year": (now) => [startOfYear(now), startOfDay(now)],
};
export const expandPresetDateRanges = (query: TChartQuery, now: Date = new Date()): TChartQuery => {
if (!query.timeDimensions?.length) return query;
const expanded = query.timeDimensions.map((td) => {
if (typeof td.dateRange !== "string") return td;
const resolver = PRESET_RESOLVERS[td.dateRange.toLowerCase().trim()];
if (!resolver) return td;
const [start, end] = resolver(now);
return {
...td,
dateRange: [formatDate(start, "yyyy-MM-dd"), formatDate(end, "yyyy-MM-dd")] as [string, string],
};
});
return { ...query, timeDimensions: expanded };
};
+6 -1
View File
@@ -1,11 +1,12 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body";
import { webhookHandler } from "@/modules/ee/billing/api/lib/stripe-webhook";
export const POST = async (request: Request) => {
try {
const body = await request.text();
const body = await readRequestBodyWithLimit(request);
const requestHeaders = await headers(); // Corrected: headers() is async
const signature = requestHeaders.get("stripe-signature");
@@ -26,6 +27,10 @@ export const POST = async (request: Request) => {
return NextResponse.json(result.message || { received: true }, { status: 200 });
} catch (error: any) {
if (error instanceof RequestBodyTooLargeError) {
return NextResponse.json({ message: "Payload Too Large" }, { status: 413 });
}
logger.error(error, `Unhandled error in Stripe webhook POST handler: ${error.message}`);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
@@ -436,17 +436,15 @@ export const PricingTable = ({
<Alert variant="info" className="max-w-4xl">
<AlertTitle>{t("workspace.settings.billing.pending_plan_change_title")}</AlertTitle>
<AlertDescription>
{t("workspace.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace(
"{{date}}",
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
)}
{t("workspace.settings.billing.pending_plan_change_description", {
plan: getCurrentCloudPlanLabel(pendingChange.targetPlan, t),
date: formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
}),
})}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
@@ -4,6 +4,7 @@ import { ZId } from "@formbricks/types/common";
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -27,6 +28,11 @@ const handleError = (err: unknown, url: string): { response: Response; error?: u
};
};
type TContactUserRequestBody = Record<string, unknown> & {
attributes?: Record<string, unknown>;
userId?: unknown;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -76,7 +82,24 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
const jsonInput = await req.json();
let jsonInput: TContactUserRequestBody;
try {
jsonInput = await parseJsonBodyWithLimit<TContactUserRequestBody>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
// Basic input validation without Zod overhead
if (
@@ -91,8 +114,13 @@ export const POST = withV1ApiWrapper({
}
// Simple email validation if present (avoid Zod)
if (jsonInput.attributes?.email) {
const email = jsonInput.attributes.email;
const attributes =
typeof jsonInput.attributes === "object" && jsonInput.attributes !== null
? jsonInput.attributes
: undefined;
if (attributes && Object.hasOwn(attributes, "email")) {
const email = attributes.email;
if (typeof email !== "string" || !email.includes("@") || email.length < 3) {
return {
response: responses.badRequestResponse("Invalid email format", undefined, true),
@@ -100,7 +128,7 @@ export const POST = withV1ApiWrapper({
}
}
const { userId, attributes } = jsonInput;
const userId = jsonInput.userId;
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);

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