Compare commits

...

39 Commits

Author SHA1 Message Date
Tiago Farto f07ab0b55a docs: clarify v3 survey logic fallback usage 2026-05-21 16:05:40 +00:00
Tiago Farto 3b8ed7d1f8 fix: add v3 locale validation error codes 2026-05-21 15:43:48 +00:00
Tiago Farto 754f4c3528 fix: improve v3 ending validation errors 2026-05-21 15:19:19 +00:00
Tiago Farto aac22a8237 Merge branch 'chore/v3_get_survey' into chore/v3_post_survey 2026-05-21 15:03:32 +00:00
Tiago Farto b525f7bdbb fix: support script-region survey locales 2026-05-21 15:00:40 +00:00
Tiago Farto 20d697f517 fix: validate v3 survey logic fallbacks 2026-05-21 14:49:00 +00:00
Tiago Farto 73c19ba823 Merge branch 'chore/v3_get_survey' into chore/v3_post_survey
# Conflicts:
#	apps/web/app/api/v3/lib/response.ts
#	docs/api-v3-reference/openapi.yml
2026-05-21 14:44:00 +00:00
Tiago Farto 9f9009497e fix: tighten v3 survey locale selectors 2026-05-21 14:40:17 +00:00
Tiago Farto 2e41f5e999 test: update validation logger assertions 2026-05-21 13:06:30 +00:00
Tiago Farto 82b912e483 chore: address code duplication 2026-05-21 12:58:51 +00:00
Tiago Farto 4e54555729 fix: improve v3 survey validation errors 2026-05-21 12:54:22 +00:00
Tiago Farto db92e94252 chore: additional error checking 2026-05-21 12:35:28 +00:00
Tiago Farto c25f908211 chore: bug fix 2026-05-21 12:30:41 +00:00
Tiago Farto e99fe2ad31 chore: improved openapi 2026-05-21 12:20:04 +00:00
Tiago Farto 336b853262 chore: renamed default language to language name (agents prefer it that way) 2026-05-21 12:13:37 +00:00
Tiago Farto e2b9cca531 chore: api v3 post survey 2026-05-21 12:02:16 +00:00
Tiago Farto 7ad0f8b21f chore: api v3 get survey 2026-05-21 11:54:46 +00:00
Dhruwang Jariwala f6aa27ba8c fix: chart date range type switch + presets include today (ENG-1034, ENG-1035) (#8096)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-21 11:05:10 +00:00
Johannes 82765f7dd7 fix: allow enterprise oauth display names (#8099)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:59:35 +00:00
Dhruwang Jariwala d5bbafcf90 fix: remount AI translation editor on value change, not disabled transition (#8084) 2026-05-21 10:09:57 +00:00
Anshuman Pandey db87a588b5 fix: adds close button on response error screen (#8093) 2026-05-21 09:26:47 +00:00
Javi Aguilar c834587c8d chore: add typecheck command and fix format and type issues (#7999) 2026-05-21 08:13:46 +00:00
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
144 changed files with 8617 additions and 1002 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
+87 -5
View File
@@ -31,14 +31,14 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Get the latest release tag from GitHub API with error handling
echo "Fetching latest release from GitHub API..."
# Use curl with error handling - API returns 404 if no releases exist
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
if [[ "$http_code" == "404" ]]; then
echo "⚠️ No previous releases found (404). This appears to be the first release."
echo "latest_release=" >> $GITHUB_OUTPUT
@@ -55,7 +55,7 @@ jobs:
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
fi
echo "Current release tag: ${{ github.event.release.tag_name }}"
- name: Compare release tags
@@ -65,7 +65,7 @@ jobs:
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
run: |
set -euo pipefail
# Handle first release case (no previous releases)
if [[ -z "${LATEST_TAG}" ]]; then
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
@@ -156,6 +156,87 @@ jobs:
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
update-helm-app-version:
name: Create Helm app version update
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- helm-chart-release
if: ${{ !github.event.release.prerelease }}
permissions:
contents: write
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Prepare Helm app version update
id: update
env:
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
echo "Helm chart appVersion already matches ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Helm app version PR
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
branch="chore/update-helm-app-version-${VERSION}"
title="chore: update Helm app version to ${VERSION}"
body_file="$(mktemp)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
git commit -m "$title"
git push --force-with-lease origin "$branch"
cat > "$body_file" <<EOF
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
Release candidates and pre-releases do not create this source update.
EOF
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
else
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
fi
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
@@ -165,6 +246,7 @@ jobs:
- docker-build-cloud
- helm-chart-release
- move-stable-tag
- update-helm-app-version
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
+19
View File
@@ -70,6 +70,25 @@ jobs:
echo "✅ Successfully updated Chart.yaml"
- name: Validate default Formbricks image tag
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
rendered="$(helm template qa charts/formbricks \
--set formbricks.webappUrl=https://qa.example.com \
--show-only templates/deployment.yaml \
--show-only templates/migration-job.yaml)"
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
if [[ "$image_count" -ne 2 ]]; then
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
exit 1
fi
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { App } from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -194,7 +194,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
href: `/workspaces/${workspace.id}/settings/workspace/general`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -467,7 +467,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton />
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
{/* Settings sidebar content */}
@@ -335,6 +335,7 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -373,12 +374,14 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -1,4 +1,11 @@
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
};
@@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
@@ -1,3 +1,18 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export default PricingPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -12,8 +13,9 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -1,9 +1,10 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -11,15 +12,19 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -1 +1,11 @@
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
@@ -1,3 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -26,8 +27,9 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
export default TeamsPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -11,6 +11,7 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -25,6 +26,7 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
@@ -103,6 +105,8 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
@@ -1,10 +1,11 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
@@ -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");
@@ -103,6 +103,7 @@ describe("getWorkspaceStateData", () => {
id: workspaceId,
appSetupCompleted: true,
workspaceSettings: {
id: workspaceId,
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -111,7 +112,14 @@ describe("getWorkspaceStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
surveys: mockWorkspaceData.surveys,
// `survey.name` is replaced with a back-compat placeholder; segment was
// null in the mock so the sanitized segment stays null.
surveys: [
{
...mockWorkspaceData.surveys[0],
name: "[deprecated] survey name omitted from public API - will be removed soon",
},
],
actionClasses: mockWorkspaceData.actionClasses,
});
@@ -211,6 +219,7 @@ describe("getWorkspaceStateData", () => {
const result = await getWorkspaceStateData(workspaceId);
expect(result.workspace.workspaceSettings).toEqual({
id: workspaceId,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
@@ -42,6 +42,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
where: { id: workspaceId },
select: {
id: true,
legacyEnvironmentId: true,
appSetupCompleted: true,
recontactDays: true,
clickOutsideClose: true,
@@ -72,7 +73,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
select: {
id: true,
welcomeCard: true,
// name intentionally omitted — internal label not needed by the SDK
// `name` deliberately not selected — internal label not needed by the
// SDK and replaced with a fixed placeholder below so older SDKs that
// decoded `Survey.name` as a required field keep working.
questions: true,
blocks: true,
variables: true,
@@ -99,9 +102,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
// Only need to know if any filters exist so we can compute
// `hasFilters`. Real filter values, segment title/description, and
// surveys-list relation are never exposed to clients.
segment: {
select: {
id: true,
@@ -135,17 +138,46 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
throw new ResourceNotFoundError("workspace", workspaceId);
}
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
// Backwards-compat response shape for SDKs from before PR #7931. Those
// clients decoded `survey.name` and the full `segment` object as required
// fields, so the response must still carry that shape — but every field
// that could leak sensitive targeting data is replaced with a placeholder.
// The actual segment-membership check happens server-side (segment IDs in
// POST /user); SDKs only inspect `filters.length` / `hasFilters` locally.
//
// `environmentId` mirrors `legacyEnvironmentId ?? workspace.id`, matching
// the `/me` endpoints' pattern so migrated workspaces keep returning the
// original env ID older clients persisted.
const legacyOrCurrentId = workspaceData.legacyEnvironmentId ?? workspaceData.id;
const placeholderDate = new Date(0);
const placeholderFilter = {
id: "placeholder",
connector: null,
resource: {
id: "placeholder",
root: { type: "device", deviceType: "phone" },
value: "deprecated",
qualifier: { operator: "equals" },
},
};
const transformedSurveys = workspaceData.surveys.map((survey) => {
const minimalSegment = survey.segment
const realHasFilters =
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
const sanitizedSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
title: "[deprecated] segment title omitted from public API - will be removed soon",
description: null,
isPrivate: true,
filters: realHasFilters ? [placeholderFilter] : [],
environmentId: legacyOrCurrentId,
workspaceId: legacyOrCurrentId,
createdAt: placeholderDate,
updatedAt: placeholderDate,
surveys: [],
hasFilters: realHasFilters,
}
: null;
@@ -155,7 +187,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
segment: null,
});
return { ...transformed, segment: minimalSegment };
return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
segment: sanitizedSegment,
};
});
return {
@@ -163,6 +199,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
id: workspaceData.id,
appSetupCompleted: workspaceData.appSetupCompleted,
workspaceSettings: {
id: workspaceData.id,
recontactDays: workspaceData.recontactDays,
clickOutsideClose: workspaceData.clickOutsideClose,
overlay: workspaceData.overlay,
@@ -171,7 +208,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: resolveStorageUrlsInObject(workspaceData.styling),
},
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
// The runtime shape carries extra back-compat fields (placeholder
// segment, `hasFilters`, mirrored `environmentId`) that aren't part of
// the modern `TJsWorkspaceStateSurvey`. Cast through unknown — this is
// intentional and only this endpoint's response widens the type.
surveys: resolveStorageUrlsInObject(transformedSurveys) as unknown as TJsWorkspaceStateSurvey[],
actionClasses: workspaceData.actionClasses,
};
} catch (error) {
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
@@ -34,6 +35,10 @@ vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
validateResponseData: mocks.validateResponseData,
@@ -123,6 +128,7 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
@@ -239,6 +245,34 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
@@ -8,6 +8,7 @@ import { THandlerParams } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -209,7 +210,7 @@ export const putResponseHandler = async ({
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { workspaceId, responseId } = params;
const { workspaceId: workspaceIdParam, responseId } = params;
if (!responseId) {
return {
@@ -217,6 +218,14 @@ export const putResponseHandler = async ({
};
}
const resolved = await resolveClientApiIds(workspaceIdParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", workspaceIdParam, true),
};
}
const { workspaceId } = resolved;
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
if ("response" in validatedUpdateInput) {
return validatedUpdateInput;
@@ -104,7 +104,11 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaClient = tx ?? prisma;
@@ -51,7 +51,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
@@ -40,7 +40,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -37,7 +37,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
@@ -21,24 +21,25 @@ vi.mock("react", async () => {
});
const contactId = "test-contact-id";
const workspaceId = "test-workspace-id";
describe("doesContactExist", () => {
describe("doesContactExistInWorkspace", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return true if contact exists", async () => {
test("should return true if contact exists in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -46,11 +47,11 @@ describe("doesContactExist", () => {
test("should return false if contact does not exist in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -1,15 +1,18 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
},
select: {
id: true,
},
});
export const doesContactExistInWorkspace = reactCache(
async (id: string, workspaceId: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
workspaceId,
},
select: {
id: true,
},
});
return !!contact;
});
return !!contact;
}
);
@@ -9,7 +9,7 @@ import {
} from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
@@ -30,7 +30,7 @@ vi.mock("@formbricks/database", () => ({
}));
vi.mock("./contact", () => ({
doesContactExist: vi.fn(),
doesContactExistInWorkspace: vi.fn(),
}));
const workspaceId = "workspace-id-mock";
@@ -81,13 +81,13 @@ describe("createDisplay", () => {
});
test("should create a display with contactId successfully", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -104,7 +104,7 @@ describe("createDisplay", () => {
const result = await createDisplay(displayInputWithoutContact);
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -115,13 +115,13 @@ describe("createDisplay", () => {
});
test("should create a display without contact if contact does not exist in the workspace", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -139,16 +139,16 @@ describe("createDisplay", () => {
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, workspaceId },
});
@@ -158,7 +158,7 @@ describe("createDisplay", () => {
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
@@ -171,7 +171,7 @@ describe("createDisplay", () => {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
@@ -179,15 +179,15 @@ describe("createDisplay", () => {
test("should throw original error on other errors during creation", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
});
test("should throw original error if doesContactExist fails", async () => {
test("should throw original error if doesContactExistInWorkspace fails", async () => {
const contactCheckError = new Error("Failed to check contact");
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
vi.mocked(doesContactExistInWorkspace).mockRejectedValue(contactCheckError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
expect(prisma.display.create).not.toHaveBeenCalled();
@@ -6,7 +6,7 @@ import {
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[workspaceId]/displays/types/display";
import { validateInputs } from "@/lib/utils/validate";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
const { contactId, surveyId, workspaceId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
const contactExists = contactId ? await doesContactExistInWorkspace(contactId, workspaceId) : false;
const survey = await prisma.survey.findUnique({
where: {
@@ -49,18 +49,7 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
return {
survey: {
@@ -84,8 +73,6 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
+41 -1
View File
@@ -135,7 +135,7 @@ describe("withV3ApiWrapper", () => {
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
workspacePermissions: [],
});
const wrapped = withV3ApiWrapper({
@@ -440,6 +440,46 @@ describe("withV3ApiWrapper", () => {
);
});
test("preserves machine-readable validation metadata from Zod issues", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.unknown().superRefine((_value, ctx) => {
ctx.addIssue({
code: "custom",
message: "Unsupported field 'extra'",
path: ["extra"],
params: { code: "unsupported_field" },
});
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: JSON.stringify({ extra: true }),
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "extra",
reason: "Unsupported field 'extra'",
code: "unsupported_field",
},
]);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+15 -4
View File
@@ -14,6 +14,7 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
isInvalidParamCode,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
@@ -70,11 +71,21 @@ function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
return "Not authenticated";
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
return error.issues.map((issue) => {
const params = "params" in issue && isPlainObject(issue.params) ? issue.params : {};
const code = isInvalidParamCode(params.code) ? params.code : undefined;
return {
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
...(code ? { code } : {}),
};
});
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
+13 -13
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
@@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
@@ -39,7 +39,7 @@ describe("requireSessionWorkspaceAccess", () => {
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(getWorkspace).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
@@ -55,11 +55,11 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(getWorkspace).not.toHaveBeenCalled();
});
test("returns 403 when workspace is not found (avoid leaking existence)", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"ws_nonexistent",
@@ -72,12 +72,12 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
@@ -102,7 +102,7 @@ describe("requireSessionWorkspaceAccess", () => {
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
@@ -144,7 +144,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" });
vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k");
});
@@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("delegates to session flow when user is present", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
@@ -179,7 +179,7 @@ describe("requireV3WorkspaceAccess", () => {
workspaceId: "proj_k",
organizationId: "org_k",
});
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k");
expect(getWorkspace).toHaveBeenCalledWith("proj_k");
});
test("returns context for API key with write on workspace", async () => {
@@ -239,7 +239,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
const auth = {
...keyBase,
workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)],
+35 -2
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "vitest";
import {
createdResponse,
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -13,7 +15,7 @@ import {
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
invalid_params: [{ name: "x", reason: "y", identifier: "canonical-x" }],
instance: "/p",
});
expect(res.status).toBe(400);
@@ -21,7 +23,7 @@ describe("v3 problem responses", () => {
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.invalid_params).toEqual([{ name: "x", reason: "y", identifier: "canonical-x" }]);
expect(body.instance).toBe("/p");
});
@@ -118,3 +120,34 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("createdResponse", () => {
test("returns 201 with Location, request id, and data envelope", async () => {
const res = createdResponse(
{ id: "survey_1" },
{
location: "/api/v3/surveys/survey_1",
requestId: "req-created",
}
);
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toBe("/api/v3/surveys/survey_1");
expect(res.headers.get("X-Request-Id")).toBe("req-created");
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
+79 -1
View File
@@ -6,7 +6,45 @@
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export const INVALID_PARAM_CODES = [
"dangling_reference",
"duplicate_identifier",
"duplicate_locale",
"forbidden_identifier",
"immutable_identifier",
"invalid_locale",
"invalid_reference",
"missing_required_field",
"missing_translation",
"unsupported_field",
] as const;
export type InvalidParamCode = (typeof INVALID_PARAM_CODES)[number];
const INVALID_PARAM_CODE_SET = new Set<InvalidParamCode>(INVALID_PARAM_CODES);
export function isInvalidParamCode(value: unknown): value is InvalidParamCode {
return typeof value === "string" && INVALID_PARAM_CODE_SET.has(value as InvalidParamCode);
}
export type InvalidParam = {
name: string;
reason: string;
code?: InvalidParamCode;
identifier?: string;
referenceType?:
| "block"
| "element"
| "ending"
| "hiddenField"
| "language"
| "variable"
| "variableName"
| "recall";
missingId?: string;
firstUsedAt?: string;
conflictsWith?: string;
};
export type ProblemExtension = {
code?: string;
@@ -171,3 +209,43 @@ export function successResponse<T>(
}
);
}
export function createdResponse<T>(
data: T,
options: { location: string; requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options.cache ?? CACHE_NO_STORE,
Location: options.location,
};
if (options.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: 201,
headers,
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -1,45 +1,34 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns workspaceId and organizationId when workspace exists", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("ws_abc");
expect(result).toEqual({
workspaceId: "ws_abc",
organizationId: "org_123",
});
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc");
expect(getWorkspace).toHaveBeenCalledWith("ws_abc");
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_abc");
});
test("resolves legacy environmentId to canonical workspaceId", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_canonical" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_456");
const result = await resolveV3WorkspaceContext("env_legacy");
expect(result).toEqual({
workspaceId: "ws_canonical",
organizationId: "org_456",
});
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_canonical");
});
test("throws when workspace does not exist", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
});
});
+5 -6
View File
@@ -6,7 +6,7 @@
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
/**
* Internal IDs derived from a V3 workspace identifier.
@@ -19,21 +19,20 @@ export type V3WorkspaceContext = {
};
/**
* Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId.
* Resolves a V3 API workspaceId to internal workspaceId and organizationId.
*
* @throws ResourceNotFoundError if the workspace does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId);
const workspace = await getWorkspace(workspaceId);
if (!workspace) {
throw new ResourceNotFoundError("workspace", workspaceId);
}
const canonicalId = workspace.id;
const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id);
return {
workspaceId: canonicalId,
workspaceId: workspace.id,
organizationId,
};
}
@@ -1,318 +0,0 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+121 -27
View File
@@ -2,42 +2,141 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { getAuthorizedV3Survey } from "../authorization";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
...(error.normalizedCode && { identifier: error.normalizedCode }),
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
params: surveyParamsSchema,
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
const { survey, authResult, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
survey.workspaceId,
"readWrite",
access: "readWrite",
requestId,
instance
);
instance,
});
if (authResult instanceof Response) {
return authResult;
if (response) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
}
if (auditLog) {
@@ -46,14 +145,9 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
return noContentResponse({ requestId });
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -0,0 +1,71 @@
import { describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { getAuthorizedV3Survey } from "./authorization";
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
const survey = {
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
};
const surveyRecord = survey as unknown as NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
describe("getAuthorizedV3Survey", () => {
test("returns a generic forbidden response when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_1",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response?.status).toBe(403);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns the authorization response when workspace access is denied", async () => {
const forbiddenResponse = new Response(null, { status: 403 });
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(forbiddenResponse);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "readWrite",
requestId: "req_2",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response).toBe(forbiddenResponse);
});
test("returns the survey and authorization context when access is allowed", async () => {
const authResult = { workspaceId: survey.workspaceId, organizationId: "org_1" };
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_3",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result).toEqual({
survey,
authResult,
response: null,
});
});
});
@@ -0,0 +1,37 @@
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden } from "@/app/api/v3/lib/response";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getSurvey } from "@/lib/survey/service";
export async function getAuthorizedV3Survey(params: {
surveyId: string;
authentication: TV3Authentication;
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
+255
View File
@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/survey/service", () => ({
createSurvey: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/permission", () => ({
getExternalUrlsPermission: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
const workspaceId = "clxx1234567890123456789012";
const rawCreateBody = {
workspaceId,
name: "Product Feedback",
defaultLanguage: "en-US",
metadata: {
cx_operation: "enterprise_onboarding",
title: { "en-US": "Product Feedback", "de-DE": "Produktfeedback" },
},
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
required: true,
},
],
},
],
};
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
const createdSurvey = {
id: "clsv1234567890123456789012",
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
languages: [],
questions: [],
welcomeCard: { enabled: false },
blocks: createBody.blocks,
endings: [],
hiddenFields: { enabled: false },
variables: [],
} as unknown as TSurvey;
type TLanguageUpsertArgs = Parameters<typeof prisma.language.upsert>[0];
type TLanguageUpsertReturn = ReturnType<typeof prisma.language.upsert>;
describe("createV3Survey", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.language.upsert).mockImplementation(
(args: TLanguageUpsertArgs): TLanguageUpsertReturn => {
const workspaceIdCode = args.where.workspaceId_code;
if (!workspaceIdCode) {
throw new Error("Expected workspaceId_code upsert selector");
}
return Promise.resolve({
id: `cllang${workspaceIdCode.code.toLowerCase().replaceAll("-", "")}`,
code: workspaceIdCode.code,
alias: null,
workspaceId: workspaceIdCode.workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
}) as TLanguageUpsertReturn;
}
);
vi.mocked(createSurvey).mockResolvedValue(createdSurvey);
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({
id: "org_1",
name: "Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
limits: { monthly: { responses: 1000 }, workspaces: 1 },
stripeCustomerId: null,
usageCycleAnchor: null,
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: undefined,
});
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
});
test("maps the public v3 body to the internal create payload", async () => {
await createV3Survey(
createBody,
{
user: { id: "user_1", email: "user@example.com", name: "User" },
expires: "2026-05-01",
},
"req_1"
);
expect(prisma.language.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { workspaceId_code: { workspaceId, code: "en-US" } },
create: { workspaceId, code: "en-US", alias: null },
})
);
expect(prisma.language.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { workspaceId_code: { workspaceId, code: "de-DE" } },
create: { workspaceId, code: "de-DE", alias: null },
})
);
expect(createSurvey).toHaveBeenCalledWith(
workspaceId,
expect.objectContaining({
name: "Product Feedback",
type: "link",
status: "draft",
createdBy: "user_1",
questions: [],
metadata: expect.objectContaining({
cx_operation: "enterprise_onboarding",
title: { default: "Product Feedback", "de-DE": "Produktfeedback" },
}),
blocks: [
expect.objectContaining({
elements: [
expect.objectContaining({
headline: {
default: "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
}),
],
}),
],
languages: [
expect.objectContaining({ default: true, enabled: true }),
expect.objectContaining({ default: false, enabled: true }),
],
})
);
expect(getOrganizationByWorkspaceId).not.toHaveBeenCalled();
expect(getExternalUrlsPermission).not.toHaveBeenCalled();
});
test("keeps createdBy null for API key calls and honors explicit disabled languages", async () => {
const body = ZV3CreateSurveyBody.parse({
...rawCreateBody,
languages: [{ code: "fr-FR", enabled: false }],
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
headline: {
...rawCreateBody.blocks[0].elements[0].headline,
"fr-FR": "Que devons-nous améliorer ?",
},
},
],
},
],
});
await createV3Survey(
body,
{
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
},
"req_2"
);
expect(createSurvey).toHaveBeenCalledWith(
workspaceId,
expect.objectContaining({
createdBy: null,
languages: expect.arrayContaining([
expect.objectContaining({ language: expect.objectContaining({ code: "fr-FR" }), enabled: false }),
]),
})
);
});
test("rejects external CTA buttons when the organization does not have external URL permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const body = ZV3CreateSurveyBody.parse({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
id: "external_cta",
type: "cta",
headline: { "en-US": "Continue" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { "en-US": "Open" },
},
],
},
],
});
await expect(createV3Survey(body, null, "req_3")).rejects.toThrow(V3SurveyCreatePermissionError);
expect(createSurvey).not.toHaveBeenCalled();
});
});
+106
View File
@@ -0,0 +1,106 @@
import "server-only";
import type { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { type TV3SurveyLanguageRequest, ensureV3WorkspaceLanguages } from "./languages";
import { prepareV3SurveyCreate } from "./prepare";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import type { TV3CreateSurveyBody } from "./schemas";
export class V3SurveyCreatePermissionError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyCreatePermissionError";
}
}
function getCreatedBy(authentication: TV3Authentication): string | null {
if (authentication && "user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
return null;
}
function hasExternalUrlReferences(input: TV3CreateSurveyBody): boolean {
const hasExternalEndingLink = input.endings.some(
(ending) => ending.type === "endScreen" && Boolean(ending.buttonLink)
);
const hasExternalCtaButton = getElementsFromBlocks(input.blocks).some(
(element) => element.type === "cta" && element.buttonExternal
);
return hasExternalEndingLink || hasExternalCtaButton;
}
async function assertV3SurveyCreatePermissions(
input: TV3CreateSurveyBody,
organizationId?: string
): Promise<void> {
if (!hasExternalUrlReferences(input)) {
return;
}
const resolvedOrganizationId =
organizationId ?? (await getOrganizationByWorkspaceId(input.workspaceId))?.id ?? null;
if (!resolvedOrganizationId) {
return;
}
const isExternalUrlsAllowed = await getExternalUrlsPermission(resolvedOrganizationId);
if (!isExternalUrlsAllowed) {
throw new V3SurveyCreatePermissionError(
"External URLs are not enabled for this organization. Upgrade to use external survey links."
);
}
}
export async function executeV3SurveyCreate(params: {
input: TV3CreateSurveyBody;
authentication: TV3Authentication;
languageRequests: TV3SurveyLanguageRequest[];
requestId?: string;
}) {
const { input, authentication, languageRequests, requestId } = params;
const languages = await ensureV3WorkspaceLanguages(input.workspaceId, languageRequests, requestId);
const surveyCreateInput: TSurveyCreateInput = {
name: input.name,
type: "link",
status: input.status,
metadata: input.metadata,
welcomeCard: input.welcomeCard,
blocks: input.blocks,
endings: input.endings,
hiddenFields: input.hiddenFields,
variables: input.variables,
languages,
questions: [],
createdBy: getCreatedBy(authentication),
};
return await createSurvey(input.workspaceId, surveyCreateInput);
}
export async function createV3Survey(
input: TV3CreateSurveyBody,
authentication: TV3Authentication,
requestId?: string,
organizationId?: string
) {
const preparation = prepareV3SurveyCreate(input);
if (!preparation.ok) {
throw new V3SurveyReferenceValidationError(preparation.validation.invalidParams);
}
await assertV3SurveyCreatePermissions(input, organizationId);
return await executeV3SurveyCreate({
input: preparation.document,
authentication,
languageRequests: preparation.languageRequests,
requestId,
});
}
@@ -0,0 +1,120 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["zh_hans_cn", "zh-Hans-CN"],
["ZH-hant-tw", "zh-Hant-TW"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
test("returns null for language-only tags", () => {
expect(normalizeV3SurveyLanguageTag("de")).toBeNull();
});
test("returns null for script-only tags without a region", () => {
expect(normalizeV3SurveyLanguageTag("zh_Hans")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us, zh_hans_cn")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US", "zh-Hans-CN"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
test("rejects language-only selectors", () => {
expect(parseV3SurveyLanguageQuery("de")).toEqual({
ok: false,
message: "Language 'de' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("matches configured script-region languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("ZH_hans_cn", [{ code: "zh-Hans-CN", enabled: true }])).toEqual({
ok: true,
code: "zh-Hans-CN",
});
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr-FR", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("ZH_hant_tw", languages)).toEqual({
ok: false,
reason: "unknown",
normalizedCode: "zh-Hant-TW",
message: "Language 'zh-Hant-TW' is not configured for this survey",
});
});
test("rejects language-only tags for surveys with a matching configured language", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({
ok: false,
reason: "invalid",
message: "Language 'de' is not a valid locale code",
});
});
test("resolves the implicit default locale for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en-US", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
export type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown"; message: string; normalizedCode?: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
const V3_SURVEY_LOCALE_CODE_REGEX = /^[a-z]{2}(?:-[A-Z][a-z]{3})?-[A-Z]{2}$/;
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
const normalizedLanguage = Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
if (!normalizedLanguage || !V3_SURVEY_LOCALE_CODE_REGEX.test(normalizedLanguage)) {
return null;
}
return normalizedLanguage;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
return {
ok: false,
reason: "unknown",
normalizedCode: normalizedRequestedLanguage,
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
export function getV3SurveyLanguages(
survey: Pick<TInternalSurvey, "languages">,
fallbackLanguage: string
): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: fallbackLanguage, default: true, enabled: true }];
}
return languages;
}
export function getV3SurveyDefaultLanguage(
survey: Pick<TInternalSurvey, "languages">,
fallbackLanguage: string
): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: fallbackLanguage;
}
@@ -0,0 +1,55 @@
import { describe, expect, test, vi } from "vitest";
import { deriveV3SurveyLanguageRequests } from "./languages";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
upsert: vi.fn(),
},
},
}));
describe("deriveV3SurveyLanguageRequests", () => {
test("derives languages from survey content and known translatable metadata fields only", () => {
const document = ZV3CreateSurveyBody.parse({
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
defaultLanguage: "en-US",
metadata: {
title: {
"en-US": "Feedback",
"de-DE": "Feedback",
},
cx_context: {
"fr-FR": "Arbitrary customer metadata, not translatable survey text",
},
},
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: {
"en-US": "What should we improve?",
"pt-BR": "O que devemos melhorar?",
},
required: true,
},
],
},
],
});
expect(deriveV3SurveyLanguageRequests(document)).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "pt-BR", default: false, enabled: true },
]);
});
});
+159
View File
@@ -0,0 +1,159 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import type { TI18nString } from "@formbricks/types/i18n";
import type { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { normalizeV3SurveyLanguageTag } from "./language";
import type { TV3SurveyDocument } from "./schemas";
export type TV3SurveyLanguageRequest = {
code: string;
default: boolean;
enabled: boolean;
};
const languageSelect = {
id: true,
code: true,
alias: true,
workspaceId: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.LanguageSelect;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isInternalI18nString(value: unknown): value is TI18nString {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function collectI18nLanguageCodes(value: unknown, languageCodes: Set<string>): void {
if (Array.isArray(value)) {
value.forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
return;
}
if (!isPlainObject(value)) {
return;
}
if (isInternalI18nString(value)) {
Object.keys(value).forEach((languageCode) => {
if (languageCode !== "default") {
const normalizedLanguageCode = normalizeV3SurveyLanguageTag(languageCode);
if (normalizedLanguageCode) {
languageCodes.add(normalizedLanguageCode);
}
}
});
return;
}
Object.values(value).forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
}
function collectMetadataI18nLanguageCodes(
metadata: TV3SurveyDocument["metadata"],
languageCodes: Set<string>
): void {
if (!isPlainObject(metadata)) {
return;
}
collectI18nLanguageCodes(metadata.title, languageCodes);
collectI18nLanguageCodes(metadata.description, languageCodes);
}
export function deriveV3SurveyLanguageRequests(input: TV3SurveyDocument): TV3SurveyLanguageRequest[] {
const requestedLanguages = new Map<string, TV3SurveyLanguageRequest>();
const addLanguage = (code: string, enabled = true): void => {
requestedLanguages.set(code, {
code,
default: code.toLowerCase() === input.defaultLanguage.toLowerCase(),
enabled: code.toLowerCase() === input.defaultLanguage.toLowerCase() ? true : enabled,
});
};
addLanguage(input.defaultLanguage);
input.languages.forEach((language) => {
addLanguage(language.code, language.enabled);
});
const contentLanguageCodes = new Set<string>();
collectI18nLanguageCodes(input.welcomeCard, contentLanguageCodes);
collectI18nLanguageCodes(input.blocks, contentLanguageCodes);
collectI18nLanguageCodes(input.endings, contentLanguageCodes);
collectMetadataI18nLanguageCodes(input.metadata, contentLanguageCodes);
contentLanguageCodes.forEach((languageCode) => {
if (!requestedLanguages.has(languageCode)) {
addLanguage(languageCode);
}
});
return Array.from(requestedLanguages.values()).sort((left, right) => {
if (left.default) return -1;
if (right.default) return 1;
return left.code.localeCompare(right.code);
});
}
export async function ensureV3WorkspaceLanguages(
workspaceId: string,
languageRequests: TV3SurveyLanguageRequest[],
requestId?: string
): Promise<TSurveyLanguage[]> {
const log = logger.withContext({ requestId, workspaceId });
try {
const languages = await Promise.all(
languageRequests.map((languageRequest) =>
prisma.language.upsert({
where: {
workspaceId_code: {
workspaceId,
code: languageRequest.code,
},
},
update: {},
create: {
workspaceId,
code: languageRequest.code,
alias: null,
},
select: languageSelect,
})
)
);
const languageByCode = new Map(languages.map((language) => [language.code.toLowerCase(), language]));
return languageRequests.map((languageRequest) => {
const language = languageByCode.get(languageRequest.code.toLowerCase());
if (!language) {
throw new DatabaseError(`Failed to resolve language '${languageRequest.code}'`);
}
return {
language,
default: languageRequest.default,
enabled: languageRequest.enabled,
};
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
log.error({ error }, "Error creating workspace languages for v3 survey write");
throw new DatabaseError(error.message);
}
throw error;
}
}
+259
View File
@@ -0,0 +1,259 @@
import { describe, expect, test, vi } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { prepareV3SurveyCreate, prepareV3SurveyCreateInput, prepareV3SurveyPatchInput } from "./prepare";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
const workspaceId = "clxx1234567890123456789012";
const rawCreateBody = {
workspaceId,
name: "Product Feedback",
defaultLanguage: "en-US",
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
required: true,
},
],
},
],
};
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
const survey = {
id: "clsv1234567890123456789012",
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
languages: [
{
language: {
id: "cllangenus000000000000000",
code: "en-US",
alias: null,
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
},
default: true,
enabled: true,
},
],
questions: [],
welcomeCard: { enabled: false },
blocks: createBody.blocks,
endings: [],
hiddenFields: { enabled: false },
variables: [],
} as unknown as TSurvey;
describe("v3 survey preparation", () => {
test("prepares a valid create document and derives language side effects", () => {
const preparation = prepareV3SurveyCreate(createBody);
expect(preparation.ok).toBe(true);
if (!preparation.ok) {
throw new Error("Expected create preparation to succeed");
}
expect(preparation.languageRequests).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
]);
});
test("returns validation results instead of throwing for invalid create input", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
buttonUrl: "https://example.com",
},
],
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.buttonUrl",
code: "unsupported_field",
}),
])
);
}
});
test("rejects configured languages that are missing from translatable survey content", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
languages: [{ code: "pt-PT", enabled: true }],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline",
code: "missing_translation",
identifier: "pt-PT",
referenceType: "language",
}),
])
);
}
});
test("rejects partial derived translations before internal survey validation", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
subheader: { "en-US": "Tell us more" },
},
],
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.subheader",
code: "missing_translation",
identifier: "de-DE",
referenceType: "language",
}),
])
);
}
});
test("returns language and reference validation issues together", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
languages: [{ code: "pt-PT", enabled: true }],
blocks: [
{
...rawCreateBody.blocks[0],
logicFallback: "clmiss12345678901234567890",
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline",
code: "missing_translation",
}),
expect.objectContaining({
name: "blocks.0.logicFallback",
code: "dangling_reference",
}),
])
);
}
});
test("applies a patch over the current document before validating references", () => {
const preparation = prepareV3SurveyPatchInput(survey, {
blocks: [
{
...rawCreateBody.blocks[0],
logicFallback: "clmiss12345678901234567890",
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "blocks.0.logicFallback" })])
);
}
});
test("rejects patch input with immutable fields as validation results", () => {
const preparation = prepareV3SurveyPatchInput(survey, {
workspaceId,
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "workspaceId",
code: "unsupported_field",
}),
])
);
}
});
test("rejects non-draft element id changes on non-draft surveys", () => {
const preparation = prepareV3SurveyPatchInput(
{
...survey,
status: "inProgress",
} as TSurvey,
{
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
id: "renamed_satisfaction",
},
],
},
],
}
);
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.id",
reason: expect.stringContaining("cannot be changed"),
code: "immutable_identifier",
identifier: "satisfaction",
referenceType: "element",
}),
])
);
}
});
});
+181
View File
@@ -0,0 +1,181 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { InvalidParam } from "@/app/api/v3/lib/response";
import { getV3SurveyDefaultLanguage, getV3SurveyLanguages } from "./language";
import { type TV3SurveyLanguageRequest, deriveV3SurveyLanguageRequests } from "./languages";
import {
DEFAULT_V3_SURVEY_LANGUAGE,
type TV3CreateSurveyBody,
type TV3PatchSurveyBody,
type TV3SurveyDocument,
ZV3CreateSurveyBody,
ZV3SurveyDocumentBase,
createZV3PatchSurveyBodySchema,
formatV3ZodInvalidParams,
} from "./schemas";
import { type TV3SurveyDocumentValidationResult, validateV3SurveyDocument } from "./validation";
type TV3SurveyPrepareSuccess<TDocument> = {
ok: true;
document: TDocument;
validation: Extract<TV3SurveyDocumentValidationResult, { valid: true }>;
languageRequests: TV3SurveyLanguageRequest[];
};
type TV3SurveyPrepareFailure = {
ok: false;
validation: Extract<TV3SurveyDocumentValidationResult, { valid: false }>;
};
export type TV3SurveyPrepareResult<TDocument> = TV3SurveyPrepareSuccess<TDocument> | TV3SurveyPrepareFailure;
function invalidPreparation(invalidParams: InvalidParam[]): TV3SurveyPrepareFailure {
return {
ok: false,
validation: {
valid: false,
invalidParams,
},
};
}
function validPreparation<TDocument extends TV3SurveyDocument>(
document: TDocument
): TV3SurveyPrepareResult<TDocument> {
const validation = validateV3SurveyDocument(document);
if (!validation.valid) {
return invalidPreparation(validation.invalidParams);
}
return {
ok: true,
document,
validation,
languageRequests: deriveV3SurveyLanguageRequests(document),
};
}
function buildDocumentFromSurvey(survey: TInternalSurvey): TV3SurveyPrepareResult<TV3SurveyDocument> {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
return invalidPreparation([
{
name: "survey",
reason: "Legacy question-based surveys are not supported by the v3 survey management API",
},
]);
}
const documentResult = ZV3SurveyDocumentBase.safeParse({
name: survey.name,
status: survey.status,
metadata: survey.metadata ?? {},
defaultLanguage: getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE),
languages: getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE),
welcomeCard: survey.welcomeCard,
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
if (!documentResult.success) {
return invalidPreparation(formatV3ZodInvalidParams(documentResult.error, "survey"));
}
return validPreparation(documentResult.data);
}
function mergeV3SurveyPatch(document: TV3SurveyDocument, patch: TV3PatchSurveyBody): TV3SurveyDocument {
return {
...document,
...Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)),
};
}
function getElementIds(document: TV3SurveyDocument): Set<string> {
return new Set(document.blocks.flatMap((block) => block.elements.map((element) => element.id)));
}
function getImmutableElementIdIssues(
currentDocument: TV3SurveyDocument,
patchedDocument: TV3SurveyDocument
): InvalidParam[] {
if (currentDocument.status === "draft") {
return [];
}
const patchedElementIds = getElementIds(patchedDocument);
const issues: InvalidParam[] = [];
currentDocument.blocks.forEach((currentBlock) => {
const patchedBlockIndex = patchedDocument.blocks.findIndex((block) => block.id === currentBlock.id);
if (patchedBlockIndex === -1) {
return;
}
const patchedBlock = patchedDocument.blocks[patchedBlockIndex];
currentBlock.elements.forEach((currentElement, elementIndex) => {
if (currentElement.isDraft || patchedElementIds.has(currentElement.id)) {
return;
}
const patchedElement = patchedBlock.elements[elementIndex];
if (!patchedElement || patchedElement.id === currentElement.id) {
return;
}
issues.push({
name: `blocks.${patchedBlockIndex}.elements.${elementIndex}.id`,
reason: `Element id '${currentElement.id}' cannot be changed because the survey and element are no longer drafts`,
code: "immutable_identifier",
identifier: currentElement.id,
referenceType: "element",
});
});
});
return issues;
}
export function prepareV3SurveyCreate(
document: TV3CreateSurveyBody
): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
return validPreparation(document);
}
export function prepareV3SurveyCreateInput(input: unknown): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
const parsed = ZV3CreateSurveyBody.safeParse(input);
if (!parsed.success) {
return invalidPreparation(formatV3ZodInvalidParams(parsed.error, "data"));
}
return prepareV3SurveyCreate(parsed.data);
}
export function prepareV3SurveyPatchInput(
survey: TInternalSurvey,
input: unknown
): TV3SurveyPrepareResult<TV3SurveyDocument> {
const currentDocument = buildDocumentFromSurvey(survey);
if (!currentDocument.ok) {
return currentDocument;
}
const parsedPatch = createZV3PatchSurveyBodySchema(currentDocument.document.defaultLanguage).safeParse(
input
);
if (!parsedPatch.success) {
return invalidPreparation(formatV3ZodInvalidParams(parsedPatch.error, "data"));
}
const patchedDocument = mergeV3SurveyPatch(currentDocument.document, parsedPatch.data);
const immutableElementIdIssues = getImmutableElementIdIssues(currentDocument.document, patchedDocument);
if (immutableElementIdIssues.length > 0) {
return invalidPreparation(immutableElementIdIssues);
}
return validPreparation(patchedDocument);
}
@@ -0,0 +1,373 @@
import { describe, expect, test } from "vitest";
import { validateV3SurveyReferences } from "./reference-validation";
import { ZV3CreateSurveyBody } from "./schemas";
const validSurvey = ZV3CreateSurveyBody.parse({
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
hiddenFields: {
enabled: true,
fieldIds: ["account_id"],
},
variables: [
{
id: "clvar123456789012345678901",
name: "score",
type: "number",
value: 0,
},
],
endings: [
{
id: "clend123456789012345678901",
type: "endScreen",
headline: { "en-US": "Thanks" },
},
],
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
logicFallback: "clend123456789012345678901",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?" },
required: true,
},
],
logic: [
{
id: "cllog123456789012345678901",
conditions: {
id: "clgrp123456789012345678901",
connector: "and",
conditions: [
{
id: "clcon123456789012345678901",
leftOperand: { type: "element", value: "satisfaction" },
operator: "isSubmitted",
},
],
},
actions: [
{
id: "clact123456789012345678901",
objective: "calculate",
variableId: "clvar123456789012345678901",
operator: "add",
value: { type: "static", value: 1 },
},
],
},
],
},
],
});
describe("validateV3SurveyReferences", () => {
test("accepts a survey with consistent stable identifiers", () => {
expect(
validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
variables: validSurvey.variables,
})
).toEqual({ ok: true, invalidParams: [] });
});
test("rejects duplicate block, element, variable, and hidden field identifiers", () => {
const survey = {
...validSurvey,
hiddenFields: { enabled: true, fieldIds: ["account_id", "account_id"] },
variables: [
...validSurvey.variables,
{
id: "clvar123456789012345678901",
name: "score",
type: "number" as const,
value: 0,
},
],
blocks: [
...validSurvey.blocks,
{
...validSurvey.blocks[0],
elements: [{ ...validSurvey.blocks[0].elements[0] }],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "blocks.1.id" }),
expect.objectContaining({ name: "blocks.1.elements.0.id" }),
expect.objectContaining({ name: "variables.1.id" }),
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
expect.objectContaining({
name: "blocks.1.id",
code: "duplicate_identifier",
identifier: "clbk1234567890123456789012",
referenceType: "block",
firstUsedAt: "blocks.0.id",
}),
])
);
}
});
test("rejects cross-namespace identifier collisions", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: { enabled: true, fieldIds: ["account_id", "satisfaction"] },
variables: [
{
id: "satisfaction",
name: "account_id",
type: "number",
value: 0,
},
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
expect.objectContaining({ name: "variables.0.id" }),
expect.objectContaining({ name: "variables.0.name" }),
expect.objectContaining({
name: "hiddenFields.fieldIds.1",
code: "duplicate_identifier",
identifier: "satisfaction",
referenceType: "hiddenField",
conflictsWith: "blocks.0.elements.0.id",
}),
])
);
}
});
test("reports dangling logic references with actionable paths", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
logicFallback: "clmiss12345678901234567890",
logic: [
{
...validSurvey.blocks[0].logic![0],
actions: [
{
...validSurvey.blocks[0].logic![0].actions[0],
variableId: "clmiss12345678901234567890",
},
{
id: "cljmp123456789012345678901",
objective: "jumpToBlock" as const,
target: "clmiss12345678901234567890",
},
],
},
],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "blocks.0.logicFallback" }),
expect.objectContaining({ name: "blocks.0.logic.0.actions.0.variableId" }),
expect.objectContaining({ name: "blocks.0.logic.0.actions.1.target" }),
expect.objectContaining({
name: "blocks.0.logic.0.actions.0.variableId",
code: "dangling_reference",
missingId: "clmiss12345678901234567890",
referenceType: "variable",
}),
])
);
}
});
test("rejects logicFallback without logic before persistence", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
logic: undefined,
logicFallback: validSurvey.endings[0].id,
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.logicFallback",
code: "invalid_reference",
reason:
"logicFallback requires at least one logic rule on the same block; omit logicFallback for normal sequential flow or add blocks[].logic",
referenceType: "ending",
}),
])
);
}
});
test("rejects logicFallback targeting the same block", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
logicFallback: validSurvey.blocks[0].id,
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.logicFallback",
code: "invalid_reference",
reason: "logicFallback cannot target the same block",
}),
])
);
}
});
test("reports dangling recall references with actionable paths", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
elements: [
{
...validSurvey.blocks[0].elements[0],
headline: {
default: "Please explain #recall:missing_id/fallback:your answer#",
},
},
],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline.default",
reason: expect.stringContaining("missing_id"),
code: "dangling_reference",
missingId: "missing_id",
referenceType: "recall",
}),
])
);
}
});
test("reports dangling recall references in survey-level translatable fields", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
metadata: {
title: {
default: "Metadata #recall:missing_metadata_reference/fallback:value#",
},
},
variables: validSurvey.variables,
welcomeCard: {
enabled: true,
headline: {
default: "Welcome #recall:missing_welcome_reference/fallback:there#",
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "welcomeCard.headline.default",
reason: expect.stringContaining("missing_welcome_reference"),
}),
expect.objectContaining({
name: "metadata.title.default",
reason: expect.stringContaining("missing_metadata_reference"),
}),
])
);
}
});
test("ignores recall-like strings in arbitrary metadata values", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
metadata: {
cx_operation: "Enterprise #recall:external_context/fallback:context#",
},
variables: validSurvey.variables,
});
expect(result).toEqual({ ok: true, invalidParams: [] });
});
});
@@ -0,0 +1,427 @@
import type { TSurveyBlocks } from "@formbricks/types/surveys/blocks";
import type { TConditionGroup, TDynamicLogicFieldValue } from "@formbricks/types/surveys/logic";
import type { TSurveyEndings, TSurveyHiddenFields, TSurveyVariables } from "@formbricks/types/surveys/types";
import type { InvalidParam } from "@/app/api/v3/lib/response";
type TReferenceValidationInput = {
blocks: TSurveyBlocks;
endings: TSurveyEndings;
hiddenFields: TSurveyHiddenFields;
metadata?: unknown;
variables: TSurveyVariables;
welcomeCard?: unknown;
};
type TNamedReference = {
id: string;
path: string;
namespace: "block" | "element" | "ending" | "hiddenField" | "variable" | "variableName";
};
type TReferenceLookup = {
elementIds: Set<string>;
variableIds: Set<string>;
hiddenFieldIds: Set<string>;
};
export class V3SurveyReferenceValidationError extends Error {
invalidParams: InvalidParam[];
constructor(invalidParams: InvalidParam[]) {
super("Survey contains invalid references");
this.name = "V3SurveyReferenceValidationError";
this.invalidParams = invalidParams;
}
}
export type TV3SurveyReferenceValidationResult =
| { ok: true; invalidParams: [] }
| { ok: false; invalidParams: InvalidParam[] };
function addDuplicateIdIssues(
entries: { id: string; path: string }[],
label: string,
referenceType: NonNullable<InvalidParam["referenceType"]>,
issues: InvalidParam[]
): void {
const firstPathById = new Map<string, string>();
entries.forEach(({ id, path }) => {
const firstPath = firstPathById.get(id);
if (firstPath !== undefined) {
issues.push({
name: path,
reason: `${label} id '${id}' is duplicated; first used at ${firstPath}`,
code: "duplicate_identifier",
identifier: id,
referenceType,
firstUsedAt: firstPath,
});
return;
}
firstPathById.set(id, path);
});
}
function addDuplicateValueIssues(
values: string[],
pathForIndex: (index: number) => string,
label: string,
referenceType: NonNullable<InvalidParam["referenceType"]>,
issues: InvalidParam[]
): void {
const firstIndexByValue = new Map<string, number>();
values.forEach((value, index) => {
const firstIndex = firstIndexByValue.get(value);
if (firstIndex !== undefined) {
issues.push({
name: pathForIndex(index),
reason: `${label} '${value}' is duplicated; first used at ${pathForIndex(firstIndex)}`,
code: "duplicate_identifier",
identifier: value,
referenceType,
firstUsedAt: pathForIndex(firstIndex),
});
return;
}
firstIndexByValue.set(value, index);
});
}
function addCrossNamespaceCollisionIssues(entries: TNamedReference[], issues: InvalidParam[]): void {
const firstEntryById = new Map<string, TNamedReference>();
entries.forEach((entry) => {
const lookupId = entry.id.toLowerCase();
const firstEntry = firstEntryById.get(lookupId);
if (!firstEntry) {
firstEntryById.set(lookupId, entry);
return;
}
if (firstEntry.namespace === entry.namespace) {
return;
}
issues.push({
name: entry.path,
reason: `${entry.namespace} identifier '${entry.id}' conflicts with ${firstEntry.namespace} identifier at ${firstEntry.path}`,
code: "duplicate_identifier",
identifier: entry.id,
referenceType: entry.namespace,
conflictsWith: firstEntry.path,
});
});
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function addRecallReferenceIssues(
value: unknown,
path: string,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
if (typeof value === "string") {
const recallPattern = /#recall:([A-Za-z0-9_-]+)/g;
for (const match of value.matchAll(recallPattern)) {
const recallId = match[1];
const isKnownReference =
references.elementIds.has(recallId) ||
references.variableIds.has(recallId) ||
references.hiddenFieldIds.has(recallId);
if (!isKnownReference) {
issues.push({
name: path,
reason: `Recall reference '${recallId}' is not defined in blocks, variables, or hiddenFields.fieldIds`,
code: "dangling_reference",
identifier: recallId,
referenceType: "recall",
missingId: recallId,
});
}
}
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) => addRecallReferenceIssues(entry, `${path}.${index}`, references, issues));
return;
}
if (!isPlainObject(value)) {
return;
}
Object.entries(value).forEach(([key, entry]) => {
addRecallReferenceIssues(entry, path ? `${path}.${key}` : key, references, issues);
});
}
function addMetadataRecallReferenceIssues(
metadata: unknown,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
if (!isPlainObject(metadata)) {
return;
}
addRecallReferenceIssues(metadata.title, "metadata.title", references, issues);
addRecallReferenceIssues(metadata.description, "metadata.description", references, issues);
}
function validateDynamicOperand(
operand: TDynamicLogicFieldValue,
path: string,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
if (operand.type === "element" && !references.elementIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Element id '${operand.value}' is not defined in blocks`,
code: "dangling_reference",
identifier: operand.value,
referenceType: "element",
missingId: operand.value,
});
}
if (operand.type === "variable" && !references.variableIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Variable id '${operand.value}' is not defined in variables`,
code: "dangling_reference",
identifier: operand.value,
referenceType: "variable",
missingId: operand.value,
});
}
if (operand.type === "hiddenField" && !references.hiddenFieldIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Hidden field id '${operand.value}' is not defined in hiddenFields.fieldIds`,
code: "dangling_reference",
identifier: operand.value,
referenceType: "hiddenField",
missingId: operand.value,
});
}
}
function validateConditionGroup(
conditionGroup: TConditionGroup,
path: string,
references: TReferenceLookup,
issues: InvalidParam[]
): void {
conditionGroup.conditions.forEach((condition, index) => {
const conditionPath = `${path}.conditions.${index}`;
if ("conditions" in condition) {
validateConditionGroup(condition, conditionPath, references, issues);
return;
}
validateDynamicOperand(condition.leftOperand, `${conditionPath}.leftOperand`, references, issues);
if (condition.rightOperand?.type && condition.rightOperand.type !== "static") {
validateDynamicOperand(condition.rightOperand, `${conditionPath}.rightOperand`, references, issues);
}
});
}
export function getV3SurveyReferenceInvalidParams(input: TReferenceValidationInput): InvalidParam[] {
const issues: InvalidParam[] = [];
const blockIds = input.blocks.map((block) => block.id);
const blockEntries = input.blocks.map((block, index) => ({
id: block.id,
path: `blocks.${index}.id`,
}));
const endingIds = input.endings.map((ending) => ending.id);
const endingEntries = input.endings.map((ending, index) => ({
id: ending.id,
path: `endings.${index}.id`,
}));
const elementEntries = input.blocks.flatMap((block, blockIndex) =>
block.elements.map((element, elementIndex) => ({
id: element.id,
path: `blocks.${blockIndex}.elements.${elementIndex}.id`,
}))
);
const elementIds = elementEntries.map((element) => element.id);
const hiddenFieldIds = input.hiddenFields.fieldIds ?? [];
const hiddenFieldEntries = hiddenFieldIds.map((id, index) => ({
id,
path: `hiddenFields.fieldIds.${index}`,
}));
const variableIds = input.variables.map((variable) => variable.id);
const variableIdEntries = variableIds.map((id, index) => ({
id,
path: `variables.${index}.id`,
}));
const variableNames = input.variables.map((variable) => variable.name);
const variableNameEntries = variableNames.map((id, index) => ({
id,
path: `variables.${index}.name`,
}));
const navigationTargetIds = new Set([...blockIds, ...endingIds]);
const navigationTargetReferenceTypes = new Map<string, "block" | "ending">([
...blockIds.map((id) => [id, "block"] as const),
...endingIds.map((id) => [id, "ending"] as const),
]);
const references = {
elementIds: new Set(elementIds),
variableIds: new Set(variableIds),
hiddenFieldIds: new Set(hiddenFieldIds),
};
addDuplicateIdIssues(blockEntries, "Block", "block", issues);
addDuplicateIdIssues(elementEntries, "Element", "element", issues);
addDuplicateIdIssues(variableIdEntries, "Variable", "variable", issues);
addDuplicateValueIssues(
hiddenFieldIds,
(index) => `hiddenFields.fieldIds.${index}`,
"Hidden field id",
"hiddenField",
issues
);
addDuplicateValueIssues(
variableNames,
(index) => `variables.${index}.name`,
"Variable name",
"variableName",
issues
);
addCrossNamespaceCollisionIssues(
[
...blockEntries.map((entry) => ({ ...entry, namespace: "block" as const })),
...elementEntries.map((entry) => ({ ...entry, namespace: "element" as const })),
...endingEntries.map((entry) => ({ ...entry, namespace: "ending" as const })),
...hiddenFieldEntries.map((entry) => ({ ...entry, namespace: "hiddenField" as const })),
...variableIdEntries.map((entry) => ({ ...entry, namespace: "variable" as const })),
...variableNameEntries.map((entry) => ({ ...entry, namespace: "variableName" as const })),
],
issues
);
input.blocks.forEach((block, blockIndex) => {
if (block.logicFallback && !block.logic?.length) {
issues.push({
name: `blocks.${blockIndex}.logicFallback`,
reason:
"logicFallback requires at least one logic rule on the same block; omit logicFallback for normal sequential flow or add blocks[].logic",
code: "invalid_reference",
identifier: block.logicFallback,
referenceType: navigationTargetReferenceTypes.get(block.logicFallback) ?? "block",
});
}
if (block.logicFallback && block.logicFallback === block.id) {
issues.push({
name: `blocks.${blockIndex}.logicFallback`,
reason: "logicFallback cannot target the same block",
code: "invalid_reference",
identifier: block.logicFallback,
referenceType: "block",
});
}
if (block.logicFallback && !navigationTargetIds.has(block.logicFallback)) {
issues.push({
name: `blocks.${blockIndex}.logicFallback`,
reason: `Logic fallback target '${block.logicFallback}' is not defined in blocks or endings`,
code: "dangling_reference",
identifier: block.logicFallback,
referenceType: "block",
missingId: block.logicFallback,
});
}
block.logic?.forEach((logic, logicIndex) => {
const logicPath = `blocks.${blockIndex}.logic.${logicIndex}`;
validateConditionGroup(logic.conditions, `${logicPath}.conditions`, references, issues);
logic.actions.forEach((action, actionIndex) => {
const actionPath = `${logicPath}.actions.${actionIndex}`;
if (action.objective === "calculate") {
if (!references.variableIds.has(action.variableId)) {
issues.push({
name: `${actionPath}.variableId`,
reason: `Variable id '${action.variableId}' is not defined in variables`,
code: "dangling_reference",
identifier: action.variableId,
referenceType: "variable",
missingId: action.variableId,
});
}
if (action.value.type !== "static") {
validateDynamicOperand(action.value, `${actionPath}.value`, references, issues);
}
}
if (action.objective === "requireAnswer" && !references.elementIds.has(action.target)) {
issues.push({
name: `${actionPath}.target`,
reason: `Element id '${action.target}' is not defined in blocks`,
code: "dangling_reference",
identifier: action.target,
referenceType: "element",
missingId: action.target,
});
}
if (action.objective === "jumpToBlock" && !navigationTargetIds.has(action.target)) {
issues.push({
name: `${actionPath}.target`,
reason: `Jump target '${action.target}' is not defined in blocks or endings`,
code: "dangling_reference",
identifier: action.target,
referenceType: "block",
missingId: action.target,
});
}
});
});
});
addRecallReferenceIssues(input.blocks, "blocks", references, issues);
addRecallReferenceIssues(input.endings, "endings", references, issues);
addRecallReferenceIssues(input.welcomeCard, "welcomeCard", references, issues);
addMetadataRecallReferenceIssues(input.metadata, references, issues);
return issues;
}
export function validateV3SurveyReferences(
input: TReferenceValidationInput
): TV3SurveyReferenceValidationResult {
const invalidParams = getV3SurveyReferenceInvalidParams(input);
if (invalidParams.length > 0) {
return { ok: false, invalidParams };
}
return { ok: true, invalidParams: [] };
}
export function assertValidV3SurveyReferences(input: TReferenceValidationInput): void {
const result = validateV3SurveyReferences(input);
if (!result.ok) {
throw new V3SurveyReferenceValidationError(result.invalidParams);
}
}
@@ -50,6 +50,10 @@ vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
};
});
vi.mock("./create", () => ({
createV3Survey: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
+88 -2
View File
@@ -1,5 +1,5 @@
/**
* GET /api/v3/surveys list surveys for a workspace.
* /api/v3/surveys list and create block-based survey management resources.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
@@ -7,6 +7,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
createdResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -14,8 +15,15 @@ import {
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import { ZV3CreateSurveyBody } from "./schemas";
import {
V3SurveyUnsupportedShapeError,
serializeV3SurveyListItem,
serializeV3SurveyResource,
} from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
@@ -80,3 +88,81 @@ export const GET = withV3ApiWrapper({
}
},
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZV3CreateSurveyBody,
},
action: "created",
targetType: "survey",
handler: async ({ authentication, auditLog, parsedInput, requestId, instance }) => {
const { body } = parsedInput;
const log = logger.withContext({ requestId, workspaceId: body.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
body.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const survey = await createV3Survey(
{
...body,
workspaceId: authResult.workspaceId,
},
authentication,
requestId,
authResult.organizationId
);
const resource = serializeV3SurveyResource(survey);
if (auditLog) {
auditLog.organizationId = authResult.organizationId;
auditLog.targetId = survey.id;
auditLog.newObject = resource;
}
return createdResponse(resource, {
requestId,
location: `/api/v3/surveys/${survey.id}`,
});
} catch (err) {
if (err instanceof V3SurveyReferenceValidationError) {
log.warn({ statusCode: 400, invalidParams: err.invalidParams }, "Survey document validation failed");
return problemBadRequest(requestId, "Invalid survey document", {
invalid_params: err.invalidParams,
instance,
});
}
if (err instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400, errorCode: err.name }, "Unsupported survey shape");
return problemBadRequest(requestId, err.message, {
invalid_params: [{ name: "body", reason: err.message }],
instance,
});
}
if (err instanceof V3SurveyCreatePermissionError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Survey create permission check failed");
return problemForbidden(requestId, err.message, instance);
}
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 survey create unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+597
View File
@@ -0,0 +1,597 @@
import { describe, expect, test } from "vitest";
import {
ZV3CreateSurveyBody,
ZV3PatchSurveyBody,
createZV3PatchSurveyBodySchema,
formatV3ZodInvalidParams,
} from "./schemas";
const validCreateBody = {
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?" },
required: true,
},
],
},
],
};
describe("ZV3CreateSurveyBody", () => {
test("accepts a valid block-based create body and applies public defaults", () => {
const parsed = ZV3CreateSurveyBody.parse(validCreateBody);
expect(parsed).toMatchObject({
workspaceId: validCreateBody.workspaceId,
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
defaultLanguage: "en-US",
languages: [],
welcomeCard: { enabled: false },
endings: [],
hiddenFields: { enabled: false },
variables: [],
});
expect(parsed.blocks[0].elements[0]).toMatchObject({
headline: { default: "What should we improve?" },
});
});
test("generates server-managed block and variable ids on create when omitted", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
blocks: [
{
name: "Generated ID Block",
elements: validCreateBody.blocks[0].elements,
},
],
variables: [
{
name: "score",
type: "number",
value: 0,
},
],
});
expect(parsed.blocks[0].id).toEqual(expect.any(String));
expect(parsed.blocks[0].id.length).toBeGreaterThan(0);
expect(parsed.variables[0].id).toEqual(expect.any(String));
expect(parsed.variables[0].id.length).toBeGreaterThan(0);
});
test("normalizes locale maps and language codes before shared survey validation", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
defaultLanguage: "en_us",
languages: [{ code: "de_de" }],
welcomeCard: {
enabled: true,
headline: { en_us: "Welcome", de_de: "Willkommen" },
},
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { en_us: "Hello", de_de: "Hallo" },
},
],
},
],
});
expect(parsed.defaultLanguage).toBe("en-US");
expect(parsed.languages).toEqual([{ code: "de-DE", enabled: true }]);
expect(parsed.welcomeCard).toMatchObject({
headline: { default: "Welcome", "de-DE": "Willkommen" },
});
expect(parsed.blocks[0].elements[0]).toMatchObject({
headline: { default: "Hello", "de-DE": "Hallo" },
});
});
test("rejects an invalid defaultLanguage instead of silently defaulting", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
defaultLanguage: "not a locale",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "defaultLanguage",
reason: "Language 'not a locale' is not a valid locale code",
code: "invalid_locale",
}),
])
);
}
});
test("rejects duplicate locale keys after normalization", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { "en-US": "Hello", en_us: "Duplicate" },
},
],
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline.en_us",
reason: "Language key 'en_us' duplicates 'en-US' after locale normalization",
code: "duplicate_locale",
}),
])
);
}
});
test("rejects unsupported top-level fields instead of silently ignoring them", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
questions: [],
styling: {},
createdBy: "user_1",
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["questions", "styling", "createdBy"])
);
});
test("rejects unsupported nested fields instead of stripping them", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
targeting: {},
elements: [
{
...validCreateBody.blocks[0].elements[0],
analytics: {},
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["blocks.0.targeting", "blocks.0.elements.0.analytics"])
);
});
test("rejects element fields that do not belong to the selected element type", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
buttonUrl: "https://example.com",
scale: "star",
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
"blocks.0.elements.0.buttonUrl"
);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain("blocks.0.elements.0.scale");
expect(
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.buttonUrl")
).toMatchObject({
message: expect.stringContaining("element type 'openText'"),
});
});
test("rejects choice fields that do not belong to the selected element type", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
id: "choices",
type: "multipleChoiceSingle",
headline: { "en-US": "Pick one" },
required: true,
choices: [
{ id: "choice_1", label: { "en-US": "A" }, imageUrl: "https://example.com/a.png" },
{ id: "choice_2", label: { "en-US": "B" } },
],
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
"blocks.0.elements.0.choices.0.imageUrl"
);
expect(
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.choices.0.imageUrl")
).toMatchObject({
message: expect.stringContaining("Allowed fields: id, label"),
});
});
test("does not rewrite locale-shaped objects in logic metadata", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
},
],
logic: [
{
id: "cllog123456789012345678901",
conditions: {
id: "clgrp123456789012345678901",
connector: "and",
conditions: [
{
id: "clcon123456789012345678901",
leftOperand: {
type: "element",
value: "satisfaction",
meta: { "en-US": "metadata" },
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: "clact123456789012345678901",
objective: "requireAnswer",
target: "satisfaction",
},
],
},
],
},
],
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("Expected schema validation to pass");
}
expect(result.data.blocks[0].logic?.[0].conditions.conditions[0]).toMatchObject({
leftOperand: {
meta: { "en-US": "metadata" },
},
});
});
test("rejects the internal default translation key in public v3 input", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { default: "Internal key should not be public" },
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path.join(".")).toBe("blocks.0.elements.0.headline.default");
});
test("preserves arbitrary metadata while normalizing known translatable metadata fields", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
metadata: {
cx_context: {
"de-DE": "This is arbitrary customer metadata, not translation content",
},
title: {
"en-US": "Feedback Survey",
"de-DE": "Feedback-Umfrage",
},
},
});
expect(parsed.metadata).toMatchObject({
cx_context: {
"de-DE": "This is arbitrary customer metadata, not translation content",
},
title: {
default: "Feedback Survey",
"de-DE": "Feedback-Umfrage",
},
});
});
test("rejects non-link survey types for this survey-template endpoint", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
type: "app",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(["type"]);
});
test("rejects malformed locale maps that do not include the default language", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { "not a locale": "Hello" },
},
],
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline.not a locale",
reason: "Language key 'not a locale' is not a valid locale code",
code: "invalid_locale",
}),
])
);
}
});
test("reports missing required element fields before shared element union errors", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
id: "feedback",
type: "openText",
headline: { "en-US": "Tell us more" },
},
],
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.required",
reason: "Missing required field 'required' for element type 'openText'",
code: "missing_required_field",
}),
])
);
}
});
test("reports missing required ending fields before shared ending union errors", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
endings: [
{
type: "endScreen",
headline: { "en-US": "Thanks!" },
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "endings.0.id",
reason: "Missing required field 'id' for ending type 'endScreen'",
code: "missing_required_field",
}),
])
);
}
});
test("reports missing ending type with a precise invalid param path", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
endings: [
{
id: "clend123456789012345678901",
headline: { "en-US": "Thanks!" },
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "endings.0.type",
reason: "Missing required field 'type' for survey ending",
code: "missing_required_field",
}),
])
);
}
});
test("rejects duplicate language entries and disabled default language", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
languages: [{ code: "en-US", enabled: false }, { code: "en_us" }],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "languages.0.enabled",
reason: "The default language cannot be disabled",
}),
expect.objectContaining({
name: "languages.1.code",
reason: "Language 'en-US' is duplicated",
code: "duplicate_locale",
}),
])
);
}
});
test("reports invalid language entries with machine-readable locale metadata", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
languages: [{ code: "de", enabled: true }],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(formatV3ZodInvalidParams(result.error, "body")).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "languages.0.code",
reason: "Language 'de' is not a valid locale code",
code: "invalid_locale",
}),
])
);
}
});
});
describe("ZV3PatchSurveyBody", () => {
test("accepts a strict top-level partial and preserves omitted defaults", () => {
const parsed = ZV3PatchSurveyBody.parse({
name: "Updated survey",
});
expect(parsed).toEqual({ name: "Updated survey" });
});
test("rejects an empty patch body", () => {
const result = ZV3PatchSurveyBody.safeParse({});
expect(result.success).toBe(false);
expect(result.error?.issues[0]).toMatchObject({
message: "Request body must include at least one updatable field",
});
});
test("rejects immutable and out-of-scope fields", () => {
const result = ZV3PatchSurveyBody.safeParse({
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
type: "link",
questions: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["id", "workspaceId", "type", "questions"])
);
});
test("normalizes patch translation maps using the current default language", () => {
const parsed = createZV3PatchSurveyBodySchema("de-DE").parse({
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { de_de: "Hallo", en_us: "Hello" },
required: true,
},
],
},
],
});
expect(parsed.blocks?.[0].elements[0]).toMatchObject({
headline: { default: "Hallo", "en-US": "Hello" },
});
expect(parsed).not.toHaveProperty("defaultLanguage");
});
test("does not generate missing ids for canonical patch documents", () => {
const result = ZV3PatchSurveyBody.safeParse({
blocks: [
{
name: "Missing ID Block",
elements: validCreateBody.blocks[0].elements,
},
],
variables: [
{
name: "score",
type: "number",
value: 0,
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["blocks.0.id", "variables.0.id"])
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,304 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {
cx: "enterprise",
arbitraryConfig: { default: "preserve-me", mode: "strict" },
title: { default: "Product Feedback", "de-DE": "Produktfeedback" },
},
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
metadata: {
cx: "enterprise",
arbitraryConfig: { default: "preserve-me", mode: "strict" },
title: {
"en-US": "Product Feedback",
"de-DE": "Produktfeedback",
},
},
});
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en-US"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("filters script-region locale selectors while preserving maps", () => {
const survey = {
...baseSurvey,
languages: [
...baseSurvey.languages,
{
default: false,
enabled: true,
language: {
id: "lang_4",
code: "zh-Hans-CN",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", zh_hans_cn: "欢迎" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["ZH_hans_cn"] });
expect(resource).toMatchObject({
welcomeCard: { headline: { "zh-Hans-CN": "欢迎" } },
});
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr-FR"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de-DE"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects language-only selectors", () => {
expect(() => serializeV3SurveyResource(baseSurvey, { lang: ["de"] })).toThrow(
"Language 'de' is not a valid locale code"
);
});
test("exposes the normalized locale code for unknown language errors", () => {
try {
serializeV3SurveyResource(baseSurvey, { lang: ["ES_es"] });
} catch (error) {
if (!(error instanceof V3SurveyLanguageError)) {
throw error;
}
expect(error.message).toBe("Language 'es-ES' is not configured for this survey");
expect(error.normalizedCode).toBe("es-ES");
return;
}
throw new Error("Expected V3SurveyLanguageError");
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+193 -3
View File
@@ -1,13 +1,203 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import {
type TV3SurveyLanguage,
getV3SurveyDefaultLanguage,
getV3SurveyLanguages,
normalizeV3SurveyLanguageTag,
resolveV3SurveyLanguageCode,
} from "./language";
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(
message: string,
readonly normalizedCode?: string
) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function serializeMetadata(
metadata: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (!isPlainObject(metadata)) {
return metadata as TSerializedValue;
}
const serializedMetadata: Record<string, TSerializedValue> = { ...metadata } as Record<
string,
TSerializedValue
>;
for (const key of ["title", "description"]) {
if (metadata[key] !== undefined) {
serializedMetadata[key] = serializeCanonicalValue(
metadata[key],
defaultLanguage,
languageCodes,
options
);
}
}
return serializedMetadata;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message, result.normalizedCode);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE);
const languages = getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: serializeMetadata(survey.metadata, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
}),
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
@@ -0,0 +1,108 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getAuthorizedV3Survey } from "../authorization";
import {
type TV3SurveyPrepareResult,
prepareV3SurveyCreateInput,
prepareV3SurveyPatchInput,
} from "../prepare";
import { type TV3SurveyDocument, ZV3EmptyQuery, ZV3SurveyValidationRequestBody } from "../schemas";
const createWorkspaceSchema = z.object({
workspaceId: z.cuid2(),
});
function serializeValidationResult<TDocument extends TV3SurveyDocument>(
operation: "create" | "patch",
preparation: TV3SurveyPrepareResult<TDocument>
) {
if (!preparation.ok) {
return {
valid: false,
operation,
invalid_params: preparation.validation.invalidParams,
};
}
return {
valid: true,
operation,
invalid_params: [],
languages: preparation.languageRequests.map((languageRequest) => ({
...languageRequest,
writeBehavior: "connect_or_create" as const,
})),
};
}
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZV3SurveyValidationRequestBody,
query: ZV3EmptyQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const { body } = parsedInput;
const log = logger.withContext({ requestId, operation: body.operation });
try {
if (body.operation === "create") {
const workspaceResult = createWorkspaceSchema.safeParse(body.data);
if (workspaceResult.success) {
const authResult = await requireV3WorkspaceAccess(
authentication,
workspaceResult.data.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
}
return successResponse(serializeValidationResult("create", prepareV3SurveyCreateInput(body.data)), {
requestId,
cache: "private, no-store",
});
}
const { survey, response } = await getAuthorizedV3Survey({
surveyId: body.surveyId,
authentication,
access: "readWrite",
requestId,
instance,
});
if (response) {
log.warn(
{ statusCode: response.status, surveyId: body.surveyId },
"Survey not found or not accessible"
);
return response;
}
return successResponse(
serializeValidationResult("patch", prepareV3SurveyPatchInput(survey, body.data)),
{
requestId,
cache: "private, no-store",
}
);
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey validation unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+145
View File
@@ -0,0 +1,145 @@
import type { InvalidParam } from "@/app/api/v3/lib/response";
import { validateV3SurveyReferences } from "./reference-validation";
import type { TV3SurveyDocument } from "./schemas";
export type TV3SurveyDocumentValidationResult =
| { valid: true; invalidParams: [] }
| { valid: false; invalidParams: InvalidParam[] };
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isInternalI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getConfiguredTranslationLanguageCodes(document: TV3SurveyDocument): string[] {
const defaultLanguage = document.defaultLanguage.toLowerCase();
const languageCodes = new Set<string>();
document.languages.forEach((language) => {
const code = language.code;
if (code.toLowerCase() !== defaultLanguage) {
languageCodes.add(code);
}
});
return Array.from(languageCodes.values());
}
function collectTranslationLanguageCodes(value: unknown, languageCodes: Set<string>): void {
if (Array.isArray(value)) {
value.forEach((entry) => collectTranslationLanguageCodes(entry, languageCodes));
return;
}
if (!isPlainObject(value)) {
return;
}
if (isInternalI18nString(value)) {
Object.keys(value).forEach((languageCode) => {
if (languageCode !== "default") {
languageCodes.add(languageCode);
}
});
return;
}
Object.values(value).forEach((entry) => collectTranslationLanguageCodes(entry, languageCodes));
}
function getRequiredTranslationLanguageCodes(document: TV3SurveyDocument): string[] {
const languageCodes = new Set(getConfiguredTranslationLanguageCodes(document));
collectTranslationLanguageCodes(document.welcomeCard, languageCodes);
collectTranslationLanguageCodes(document.blocks, languageCodes);
collectTranslationLanguageCodes(document.endings, languageCodes);
return Array.from(languageCodes.values());
}
function addMissingTranslationIssues(
value: unknown,
path: string,
languageCodes: string[],
issues: InvalidParam[]
): void {
if (languageCodes.length === 0) {
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) =>
addMissingTranslationIssues(entry, path ? `${path}.${index}` : String(index), languageCodes, issues)
);
return;
}
if (!isPlainObject(value)) {
return;
}
if (isInternalI18nString(value)) {
languageCodes.forEach((languageCode) => {
if (value[languageCode] === undefined) {
issues.push({
name: path,
reason: `Translatable field is missing configured language '${languageCode}'`,
code: "missing_translation",
identifier: languageCode,
referenceType: "language",
missingId: languageCode,
});
}
});
return;
}
Object.entries(value).forEach(([key, entry]) =>
addMissingTranslationIssues(entry, path ? `${path}.${key}` : key, languageCodes, issues)
);
}
function getV3SurveyLanguageInvalidParams(document: TV3SurveyDocument): InvalidParam[] {
const languageCodes = getRequiredTranslationLanguageCodes(document);
const issues: InvalidParam[] = [];
addMissingTranslationIssues(document.welcomeCard, "welcomeCard", languageCodes, issues);
addMissingTranslationIssues(document.blocks, "blocks", languageCodes, issues);
addMissingTranslationIssues(document.endings, "endings", languageCodes, issues);
return issues;
}
export function validateV3SurveyDocument(document: TV3SurveyDocument): TV3SurveyDocumentValidationResult {
const languageInvalidParams = getV3SurveyLanguageInvalidParams(document);
const invalidParams = [...languageInvalidParams];
const referenceValidation = validateV3SurveyReferences({
blocks: document.blocks,
endings: document.endings,
hiddenFields: document.hiddenFields,
metadata: document.metadata,
variables: document.variables,
welcomeCard: document.welcomeCard,
});
if (!referenceValidation.ok) {
invalidParams.push(...referenceValidation.invalidParams);
}
if (invalidParams.length > 0) {
return {
valid: false,
invalidParams,
};
}
return { valid: true, invalidParams: [] };
}
+44
View File
@@ -4,6 +4,7 @@ import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
} from "./service";
@@ -207,4 +208,47 @@ describe("AI organization service", () => {
);
});
});
describe("getAISmartToolsUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
test("returns undefined when all checks pass", () => {
expect(getAISmartToolsUnavailableReason(baseConfig)).toBeUndefined();
});
test("returns not_in_plan when smart tools entitlement is missing", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEntitled: false })).toBe(
"not_in_plan"
);
});
test("returns not_enabled when smart tools is disabled at org level", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEnabled: false })).toBe(
"not_enabled"
);
});
test("returns instance_not_configured when instance AI is missing", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
"instance_not_configured"
);
});
test("ignores data-analysis flags (smart tools is independent of data analysis state)", () => {
expect(
getAISmartToolsUnavailableReason({
...baseConfig,
isAIDataAnalysisEntitled: false,
isAIDataAnalysisEnabled: false,
})
).toBeUndefined();
});
});
});
+9
View File
@@ -59,6 +59,15 @@ export const getAIDataAnalysisUnavailableReason = (
return undefined;
};
export const getAISmartToolsUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
if (!aiConfig.isAISmartToolsEntitled) return "not_in_plan";
if (!aiConfig.isAISmartToolsEnabled) return "not_enabled";
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
return undefined;
};
export const assertOrganizationAIConfigured = async (
organizationId: string,
capability: "smartTools" | "dataAnalysis"
+2 -1
View File
@@ -1,5 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
+103
View File
@@ -733,6 +733,85 @@ describe("Tests for createSurvey", () => {
})
);
});
test("creates survey languages from validated language inputs", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
languages: [
{
default: true,
enabled: true,
language: {
id: "cllang12345678901234567890",
code: "en-US",
alias: null,
workspaceId: mockWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
languages: {
create: [
{
language: {
connect: {
id: "cllang12345678901234567890",
},
},
default: true,
enabled: true,
},
],
},
}),
})
);
});
test("preserves an explicitly provided segment relation for existing callers", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
segment: {
id: "clseg123456789012345678901",
title: "Segment",
description: null,
isPrivate: false,
filters: [],
workspaceId: mockWorkspaceId,
surveys: [],
createdAt: new Date(),
updatedAt: new Date(),
},
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
segment: {
connect: {
id: "clseg123456789012345678901",
},
},
}),
})
);
});
});
describe("Sad Path", () => {
@@ -745,6 +824,30 @@ describe("Tests for createSurvey", () => {
);
});
test("rejects survey languages from a different workspace", async () => {
await expect(
createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
languages: [
{
default: true,
enabled: true,
language: {
id: "cllang12345678901234567890",
code: "en-US",
alias: null,
workspaceId: "clotherworkspace0000000000",
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
})
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.survey.create).not.toHaveBeenCalled();
});
test("throws DatabaseError if there is a Prisma error", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
const mockError = new Prisma.PrismaClientKnownRequestError("Database error", {
+30 -7
View File
@@ -621,6 +621,17 @@ const validateSurveyCreateDataMedia = (
return data;
};
const assertSurveyLanguagesBelongToWorkspace = (
workspaceId: string,
languages: TSurveyCreateInput["languages"]
): void => {
for (const surveyLanguage of languages ?? []) {
if (surveyLanguage.language.workspaceId !== workspaceId) {
throw new ResourceNotFoundError("Language", surveyLanguage.language.id);
}
}
};
export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreateInput): Promise<TSurvey> => {
const [parsedWorkspaceId, parsedSurveyBody] = validateInputs(
[workspaceId, ZId],
@@ -628,9 +639,24 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
);
try {
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const { createdBy, languages, segment, followUps, ...restSurveyBody } = parsedSurveyBody;
assertSurveyLanguagesBelongToWorkspace(parsedWorkspaceId, languages);
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const surveyLanguagesCreateData: Prisma.SurveyLanguageCreateNestedManyWithoutSurveyInput | undefined =
languages?.length
? {
create: languages.map((surveyLanguage) => ({
language: {
connect: {
id: surveyLanguage.language.id,
},
},
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
})),
}
: undefined;
const actionClasses = await getActionClasses(parsedWorkspaceId);
@@ -641,18 +667,15 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
publishOn: normalizedPublishOn,
status: restSurveyBody.status ?? "draft",
}),
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
languages: surveyLanguagesCreateData,
segment: segment?.id ? { connect: { id: segment.id } } : undefined,
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
const data = validateSurveyCreateDataMedia(
attachSurveyFollowUpsToCreateData(
attachSurveyCreatorToCreateData(baseData, createdBy),
restSurveyBody.followUps
)
attachSurveyFollowUpsToCreateData(attachSurveyCreatorToCreateData(baseData, createdBy), followUps)
);
const organization = await getOrganizationByWorkspaceId(parsedWorkspaceId);
@@ -38,6 +38,50 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p }));
const buffer = convertToXlsxBuffer(["name"], rows);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
payloads.forEach((p, i) => {
const cell = sheet[`A${i + 2}`]; // row 1 is header
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${p}`);
});
});
test("should defang formula injection in xlsx header names", () => {
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const headerCell = sheet["A1"];
expect(headerCell.f).toBeUndefined();
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
+26 -2
View File
@@ -2,11 +2,30 @@ import { AsyncParser } from "@json2csv/node";
import * as xlsx from "xlsx";
import { logger } from "@formbricks/logger";
// Defang spreadsheet formula injection. Cell values starting with
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
// Sanitize at the render boundary only — never rewrite row keys, since
// distinct user-controlled labels could collide after prefixing (e.g.
// "=field" and "'=field" both map to "'=field"), dropping cell data.
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
const sanitizeFormulaInjection = <T>(value: T): T => {
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
return `'${value}` as T;
}
return value;
};
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
let csv: string = "";
// Field descriptors preserve the original lookup key while overriding the
// rendered label and cell value with sanitized versions.
const parser = new AsyncParser({
fields,
fields: fields.map((name) => ({
label: sanitizeFormulaInjection(name),
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
})),
});
try {
@@ -23,8 +42,13 @@ export const convertToXlsxBuffer = (
fields: string[],
jsonData: Record<string, string | number>[]
): Buffer => {
// Build as array-of-arrays so original row keys are looked up before
// sanitization is applied to the rendered header/cell only.
const headerRow = fields.map(sanitizeFormulaInjection);
const dataRows = jsonData.map((row) => fields.map((name) => sanitizeFormulaInjection(row[name])));
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};
+20 -4
View File
@@ -27,8 +27,16 @@ describe("validateInputs", () => {
expect(() => validateInputs([123, schema])).toThrow(ValidationError);
expect(logger.error).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining("Validation failed")
expect.objectContaining({
error: expect.any(z.ZodError),
issues: expect.arrayContaining([
expect.objectContaining({
message: "Invalid input: expected string, received number",
}),
]),
valuePreview: "123",
}),
"Input validation failed"
);
});
@@ -47,8 +55,16 @@ describe("validateInputs", () => {
expect(() => validateInputs(["valid", stringSchema], ["invalid", numberSchema])).toThrow(ValidationError);
expect(logger.error).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining("Validation failed")
expect.objectContaining({
error: expect.any(z.ZodError),
issues: expect.arrayContaining([
expect.objectContaining({
message: "Invalid input: expected number, received string",
}),
]),
valuePreview: '"invalid"',
}),
"Input validation failed"
);
});
});
+6 -2
View File
@@ -20,8 +20,12 @@ export function validateInputs<T extends ValidationPair<any>[]>(
.join("; ");
logger.error(
inputValidation.error,
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
{
error: inputValidation.error,
issues: inputValidation.error.issues,
valuePreview: JSON.stringify(value).substring(0, 100),
},
"Input validation failed"
);
throw new ValidationError(`Validation failed: ${zodDetails}`);
}
+1 -1
View File
@@ -2429,7 +2429,7 @@
"most_popular": "Самый популярный",
"pending_change_removed": "Запланированное изменение тарифа отменено.",
"pending_plan_badge": "Запланирован",
"pending_plan_change_description": "Твой тариф сменится на {plan} {date}.",
"pending_plan_change_description": "Твой тариф сменится на {plan} на {date}.",
"pending_plan_change_title": "Запланированное изменение тарифа",
"pending_plan_cta": "Запланирован",
"per_month": "в месяц",
+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,
@@ -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,10 @@ export const generateAIChartAction = authenticatedActionClient
await checkDashboardsEnabled(organizationId);
// Verify AI is entitled, enabled at org level, and configured at instance level
await assertOrganizationAIConfigured(organizationId, "dataAnalysis");
// Verify AI is entitled, enabled at org level, and configured at instance level.
// Uses "smartTools" (not "dataAnalysis") because chart generation only sends the
// Cube schema context and the user's prompt to the LLM — no response PII.
await assertOrganizationAIConfigured(organizationId, "smartTools");
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
@@ -1,5 +1,5 @@
import { use } from "react";
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -87,7 +87,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
getConnectorsWithMappings(workspaceId),
getOrganizationAIConfig(organization.id),
]);
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
const isAIAvailable = !aiUnavailableReason;
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
@@ -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,96 @@
import { describe, expect, test } from "vitest";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "./date-presets";
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
});
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
describe("expandPresetDateRanges", () => {
test("includes today for 'last 7 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
});
test("includes today for 'last 30 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
});
test("expands 'today' to today..today", () => {
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
});
test("expands 'yesterday' to yesterday..yesterday", () => {
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
});
test("'this month' runs from the 1st through today", () => {
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
});
test("'last month' is the full previous calendar month", () => {
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
});
test("'last month' handles year rollover", () => {
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
});
test("'this quarter' starts at the first day of the calendar quarter", () => {
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
});
test("'this year' starts on Jan 1", () => {
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
});
test("leaves explicit [start, end] tuple unchanged", () => {
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
});
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
});
test("returns input unchanged when there are no time dimensions", () => {
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
});
test("preserves other timeDimension fields (granularity, dimension)", () => {
const q: TChartQuery = {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
],
};
const result = expandPresetDateRanges(q, NOW);
expect(result.timeDimensions?.[0]).toMatchObject({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: ["2026-05-15", "2026-05-21"],
});
});
test("does not mutate the input query", () => {
const q = queryWithDateRange("last 7 days");
const before = JSON.stringify(q);
expandPresetDateRanges(q, NOW);
expect(JSON.stringify(q)).toBe(before);
});
});
@@ -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 };
};
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -6,6 +8,17 @@ import {
getFilterOperatorsForType,
} from "./schema-definition";
const chartCubeSchemaPath = fileURLToPath(
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
);
const dockerCubeSchemaPath = fileURLToPath(
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
);
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
describe("schema-definition", () => {
describe("getFilterOperatorsForType", () => {
test("returns string operators", () => {
@@ -94,5 +107,20 @@ describe("schema-definition", () => {
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
test("only exposes members present in the deployed Cube schema", () => {
const chartCubeSchema = readChartCubeSchema();
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
getCubeMemberName(id)
);
for (const member of exposedMembers) {
expect(chartCubeSchema).toContain(` ${member}: {`);
}
});
test("keeps the Helm and Docker Cube schemas in sync", () => {
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
});
});
});
@@ -436,17 +436,15 @@ export const PricingTable = ({
<Alert variant="info" className="max-w-4xl">
<AlertTitle>{t("workspace.settings.billing.pending_plan_change_title")}</AlertTitle>
<AlertDescription>
{t("workspace.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace(
"{{date}}",
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
)}
{t("workspace.settings.billing.pending_plan_change_description", {
plan: getCurrentCloudPlanLabel(pendingChange.targetPlan, t),
date: formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
}),
})}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
@@ -13,7 +13,7 @@ export const ManageTeam = () => {
const router = useRouter();
const handleManageTeams = () => {
router.push(`${workspaceBasePath}/settings/teams`);
router.push(`${workspaceBasePath}/settings/organization/teams`);
};
return (
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
{(!workspace || workspace.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
@@ -123,11 +123,7 @@ export const SurveyLoadingAnimation = ({
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
)}>
{isBrandingEnabled && (
<Image
src={Logo as string}
alt="Logo"
className={cn("w-32 transition-all duration-1000 md:w-40")}
/>
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
)}
<LoadingSpinner />
</div>
@@ -1,6 +1,7 @@
"use client";
import { EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { CopyIcon, EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -9,9 +10,12 @@ import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { copySurveyToOtherWorkspaceAction } from "@/modules/survey/list/actions";
import { surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
@@ -42,9 +46,11 @@ export const SurveyDropDownMenu = ({
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const router = useRouter();
const queryClient = useQueryClient();
const editHref = `/workspaces/${workspace?.id}/surveys/${survey.id}/edit`;
@@ -85,6 +91,29 @@ export const SurveyDropDownMenu = ({
setIsCautionDialogOpen(true);
};
const handleDuplicateSurvey = async () => {
if (!workspace?.id) return;
setIsDuplicating(true);
setIsDropDownOpen(false);
try {
const response = await copySurveyToOtherWorkspaceAction({
surveyId: survey.id,
targetWorkspaceId: workspace.id,
});
if (response?.data) {
toast.success(t("workspace.surveys.survey_duplicated_successfully"));
await queryClient.invalidateQueries({ queryKey: surveyKeys.lists() });
return;
}
toast.error(getFormattedErrorMessage(response));
} catch (error) {
logger.error(error);
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDuplicating(false);
}
};
if (!hasVisibleActions) {
return null;
}
@@ -120,6 +149,22 @@ export const SurveyDropDownMenu = ({
</Link>
</DropdownMenuItem>
)}
{canManageSurvey && (
<DropdownMenuItem>
<button
type="button"
data-testid="duplicate-survey"
className={cn("flex w-full items-center", isDuplicating && "cursor-not-allowed opacity-50")}
disabled={isDuplicating}
onClick={(e) => {
e.preventDefault();
void handleDuplicateSurvey();
}}>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
)}
{canPreviewOrCopyLink && (
<DropdownMenuItem>
<button
@@ -99,12 +99,7 @@ describe("useDeleteSurvey", () => {
0
);
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
resolveFetch?.(new Response(null, { status: 204 }));
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
@@ -1,5 +1,10 @@
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { V3ApiError } from "@/modules/api/lib/v3-client";
import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
@@ -39,3 +44,39 @@ describe("buildSurveyListSearchParams", () => {
);
});
});
describe("deleteSurvey", () => {
test("treats 204 No Content as a successful delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await expect(deleteSurvey("survey_1")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", {
method: "DELETE",
cache: "no-store",
});
});
test("maps v3 problem responses to V3ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json(
{
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
},
{ status: 403 }
)
)
);
await expect(deleteSurvey("survey_1")).rejects.toMatchObject<V3ApiError>({
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
});
});
});
@@ -13,12 +13,6 @@ type TV3SurveyListResponse = {
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
@@ -122,7 +116,7 @@ export async function listSurveys({
};
}
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
export async function deleteSurvey(surveyId: string): Promise<void> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
@@ -131,7 +125,4 @@ export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
@@ -26,17 +26,21 @@ export const RichTextTranslationInput = ({
}: RichTextTranslationInputProps) => {
const [firstRender, setFirstRender] = useState(true);
const [editorKey, setEditorKey] = useState(0);
const prevDisabledRef = useRef(disabled);
// Separates external value changes (e.g. AI fill) from the editor's own write-back so we
// only remount for the former.
const lastWrittenRef = useRef(value);
// Suppresses Lexical's mount-time empty listener fire which would otherwise clobber an
// externally-applied value back to "".
const initialContentSetRef = useRef(false);
// Remount the editor when AI translation finishes (disabled transitions from true → false)
// so the editor picks up the externally populated value.
useEffect(() => {
if (prevDisabledRef.current && !disabled) {
if (value !== lastWrittenRef.current) {
lastWrittenRef.current = value;
initialContentSetRef.current = false;
setEditorKey((k) => k + 1);
setFirstRender(true);
}
prevDisabledRef.current = disabled;
}, [disabled]);
}, [value]);
return (
<div className={disabled ? "cursor-not-allowed rounded-md opacity-60" : "rounded-md"}>
@@ -47,7 +51,12 @@ export const RichTextTranslationInput = ({
firstRender={firstRender}
setFirstRender={setFirstRender}
getText={() => md.render(value)}
setText={(v: string) => onChange(path, v)}
setText={(v: string) => {
if (!initialContentSetRef.current && v === "") return;
initialContentSetRef.current = true;
lastWrittenRef.current = v;
onChange(path, v);
}}
localSurvey={localSurvey}
elementId={elementId}
selectedLanguageCode={languageCode}
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
<DropdownMenuPrimitive.SubContent
ref={ref as any}
className={cn(
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
className
)}
{...props}
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
ref={ref}
sideOffset={sideOffset}
className={cn(
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -5,9 +5,14 @@ import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
export const GoBackButton = ({ url }: { url?: string }) => {
interface GoBackButtonProps {
url?: string;
}
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
const router = useRouter();
const { t } = useTranslation();
return (
<Button
size="sm"
@@ -17,6 +22,7 @@ export const GoBackButton = ({ url }: { url?: string }) => {
router.push(url);
return;
}
router.back();
}}>
<ArrowLeftIcon />
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
align={align}
sideOffset={sideOffset}
className={cn(
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
ref={ref}
sideOffset={sideOffset}
className={cn(
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
className
)}
{...props}
@@ -34,7 +34,6 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ workspac
<IdBadge
id={workspace.legacyEnvironmentId}
label={t("workspace.app-connection.environment_id_legacy")}
copyDisabled
/>
)}
<IdBadge id={WEBAPP_URL} label={t("workspace.app-connection.webapp_url")} />
+2
View File
@@ -10,6 +10,8 @@
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
"build:dev": "pnpm run build",
"start": "next start",
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
+3 -1
View File
@@ -224,7 +224,9 @@ test.describe("Survey overview", () => {
});
await page.locator("[data-testid='survey-dropdown-trigger']").click();
await expect(page.getByText("Duplicate", { exact: true })).toHaveCount(0);
// Duplicate stays visible for users who can manage surveys (works on drafts too —
// it creates another draft via copySurveyToOtherWorkspaceAction).
await expect(page.getByTestId("duplicate-survey")).toBeVisible();
await expect(page.getByText("Copy...", { exact: true })).toHaveCount(0);
await expect(page.getByText("Preview", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("copy-link")).toHaveCount(0);
+8
View File
@@ -0,0 +1,8 @@
import "@prisma/client";
declare module "@prisma/client" {
namespace Prisma {
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
}
}

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