mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-23 02:45:21 -05:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b525f7bdbb | |||
| 9f9009497e | |||
| 7ad0f8b21f | |||
| f6aa27ba8c | |||
| 82765f7dd7 | |||
| d5bbafcf90 | |||
| db87a588b5 | |||
| c834587c8d | |||
| ef18aacfa2 | |||
| 025a766c57 | |||
| f476db3128 | |||
| 37023275ca | |||
| 9266f64588 | |||
| 032066194b | |||
| 0bef023302 | |||
| aa83ee336c | |||
| 4357f497a1 | |||
| 526c17af23 | |||
| a0ddadebad | |||
| bc0d04f5e8 | |||
| f0967c2e23 | |||
| 13c9677edd | |||
| c0bf2ab7cc | |||
| 65d0f4ac0e | |||
| 655c0b5e47 |
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,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}</>;
|
||||
};
|
||||
|
||||
|
||||
+54
@@ -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) {
|
||||
|
||||
+9
-4
@@ -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) {
|
||||
|
||||
+11
-1
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
+34
@@ -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(),
|
||||
|
||||
+10
-1
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
noContentResponse,
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
@@ -13,7 +14,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 +22,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 +119,13 @@ describe("successResponse", () => {
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
|
||||
});
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
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 type InvalidParam = { name: string; reason: string; identifier?: string };
|
||||
|
||||
export type ProblemExtension = {
|
||||
code?: string;
|
||||
@@ -171,3 +171,18 @@ export function successResponse<T>(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
type TV3SurveyLanguageInput = {
|
||||
code: string;
|
||||
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`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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" },
|
||||
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({
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,195 @@
|
||||
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 { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
|
||||
|
||||
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
|
||||
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
|
||||
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
|
||||
|
||||
type TV3SurveyLanguage = {
|
||||
code: string;
|
||||
default: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
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 getSurveyLanguages(survey: TInternalSurvey): 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: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
|
||||
}
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
function getDefaultLanguage(survey: TInternalSurvey): string {
|
||||
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
|
||||
.code;
|
||||
return defaultLanguageCode
|
||||
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
|
||||
: DEFAULT_V3_SURVEY_LANGUAGE;
|
||||
}
|
||||
|
||||
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 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 = getDefaultLanguage(survey);
|
||||
const languages = getSurveyLanguages(survey);
|
||||
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: survey.metadata,
|
||||
defaultLanguage,
|
||||
languages,
|
||||
welcomeCard: serializeValue(survey.welcomeCard),
|
||||
blocks: serializeValue(survey.blocks),
|
||||
endings: serializeValue(survey.endings),
|
||||
hiddenFields: survey.hiddenFields,
|
||||
variables: survey.variables,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
};
|
||||
|
||||
@@ -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": "в месяц",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+16
-7
@@ -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")} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"exclude": [
|
||||
"../../.env",
|
||||
".next",
|
||||
"node_modules",
|
||||
"playwright",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/tests/**",
|
||||
"**/__mocks__/**",
|
||||
"**/__tests__/**"
|
||||
],
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.d.ts",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"lib/**/*.tsx",
|
||||
"modules/**/*.ts",
|
||||
"modules/**/*.tsx",
|
||||
"scripts/**/*.ts",
|
||||
"../../packages/types/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ type: application
|
||||
version: 0.0.0-dev
|
||||
|
||||
# This is the version number of the application being deployed.
|
||||
appVersion: "3.7.0"
|
||||
appVersion: "5.0.0-rc.1"
|
||||
|
||||
icon: https://formbricks.com/favicon.ico
|
||||
|
||||
|
||||
+213
-212
@@ -1,6 +1,6 @@
|
||||
# formbricks
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
A Helm chart for Formbricks with PostgreSQL, Redis
|
||||
|
||||
@@ -55,7 +55,8 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
|
||||
when using the default release name.
|
||||
- For an external Cube, set `cube.enabled: false` and point `deployment.env.CUBEJS_API_URL` at your
|
||||
endpoint.
|
||||
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
|
||||
- The generated app secret supplies `CUBEJS_API_SECRET` by default. If you disable generated secrets,
|
||||
provide it through your existing secret management flow.
|
||||
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
|
||||
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
|
||||
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
|
||||
@@ -92,213 +93,213 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
|
||||
## Values
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------------------------------------------------ | ------ | --------------------------------- | ----------- |
|
||||
| autoscaling.additionalLabels | object | `{}` | |
|
||||
| autoscaling.annotations | object | `{}` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].periodSeconds | int | `120` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].type | string | `"Pods"` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].value | int | `1` | |
|
||||
| autoscaling.behavior.scaleDown.stabilizationWindowSeconds | int | `300` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].periodSeconds | int | `60` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].type | string | `"Pods"` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].value | int | `2` | |
|
||||
| autoscaling.behavior.scaleUp.stabilizationWindowSeconds | int | `60` | |
|
||||
| autoscaling.enabled | bool | `true` | |
|
||||
| autoscaling.maxReplicas | int | `10` | |
|
||||
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
|
||||
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[0].type | string | `"Resource"` | |
|
||||
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
|
||||
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[1].type | string | `"Resource"` | |
|
||||
| autoscaling.minReplicas | int | `1` | |
|
||||
| componentOverride | string | `""` | |
|
||||
| deployment.additionalLabels | object | `{}` | |
|
||||
| deployment.additionalPodAnnotations | object | `{}` | |
|
||||
| deployment.additionalPodLabels | object | `{}` | |
|
||||
| deployment.affinity | object | `{}` | |
|
||||
| deployment.annotations | object | `{}` | |
|
||||
| deployment.args | list | `[]` | |
|
||||
| deployment.command | list | `[]` | |
|
||||
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
|
||||
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| deployment.env | object | `{}` | |
|
||||
| deployment.envFrom | string | `nil` | |
|
||||
| deployment.image.digest | string | `""` | |
|
||||
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
|
||||
| deployment.image.tag | string | `""` | |
|
||||
| deployment.imagePullSecrets | string | `""` | |
|
||||
| deployment.nodeSelector | object | `{}` | |
|
||||
| deployment.ports.http.containerPort | int | `3000` | |
|
||||
| deployment.ports.http.exposed | bool | `true` | |
|
||||
| deployment.ports.http.protocol | string | `"TCP"` | |
|
||||
| deployment.ports.metrics.containerPort | int | `9464` | |
|
||||
| deployment.ports.metrics.exposed | bool | `true` | |
|
||||
| deployment.ports.metrics.protocol | string | `"TCP"` | |
|
||||
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
|
||||
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
|
||||
| deployment.reloadOnChange | bool | `false` | |
|
||||
| deployment.replicas | int | `1` | |
|
||||
| deployment.resources.limits.memory | string | `"2Gi"` | |
|
||||
| deployment.resources.requests.cpu | string | `"1"` | |
|
||||
| deployment.resources.requests.memory | string | `"1Gi"` | |
|
||||
| deployment.revisionHistoryLimit | int | `2` | |
|
||||
| deployment.securityContext | object | `{}` | |
|
||||
| deployment.strategy.type | string | `"RollingUpdate"` | |
|
||||
| deployment.tolerations | list | `[]` | |
|
||||
| deployment.topologySpreadConstraints | list | `[]` | |
|
||||
| enterprise.enabled | bool | `false` | |
|
||||
| enterprise.licenseKey | string | `""` | |
|
||||
| externalSecret.enabled | bool | `false` | |
|
||||
| externalSecret.files | object | `{}` | |
|
||||
| externalSecret.refreshInterval | string | `"1h"` | |
|
||||
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
|
||||
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
|
||||
| formbricks.publicUrl | string | `""` | |
|
||||
| formbricks.webappUrl | string | `""` | |
|
||||
| hub.autoscaling.enabled | bool | `false` | |
|
||||
| hub.autoscaling.maxReplicas | int | `3` | |
|
||||
| hub.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.enabled | bool | `true` | |
|
||||
| hub.embeddings.auth.enabled | bool | `true` | |
|
||||
| hub.embeddings.auth.existingSecret | string | `""` | |
|
||||
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
|
||||
| hub.embeddings.autoscaling.enabled | bool | `false` | |
|
||||
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
|
||||
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
|
||||
| hub.embeddings.enabled | bool | `false` | |
|
||||
| hub.embeddings.extraArgs | list | `["--dtype","float16"]` | Additional args appended to the generated TEI args. |
|
||||
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
|
||||
| hub.embeddings.huggingFace.token | string | `""` | |
|
||||
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
|
||||
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
|
||||
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
|
||||
| hub.embeddings.maxConcurrent | string | `"5"` | |
|
||||
| hub.embeddings.model | string | `"Alibaba-NLP/gte-multilingual-base"` | |
|
||||
| hub.embeddings.persistence.enabled | bool | `true` | |
|
||||
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
|
||||
| hub.embeddings.persistence.size | string | `"10Gi"` | |
|
||||
| hub.embeddings.pdb.enabled | bool | `false` | |
|
||||
| hub.embeddings.port | int | `8080` | |
|
||||
| hub.embeddings.prometheusPort | int | `9000` | |
|
||||
| hub.embeddings.replicas | int | `1` | |
|
||||
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
|
||||
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
|
||||
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
|
||||
| hub.embeddings.runtime | string | `"tei"` | |
|
||||
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
|
||||
| hub.embeddings.service.port | int | `8080` | |
|
||||
| hub.embeddings.service.type | string | `"ClusterIP"` | |
|
||||
| hub.env | object | `{}` | |
|
||||
| hub.existingSecret | string | `""` | |
|
||||
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
|
||||
| hub.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| hub.image.repository | string | `"ghcr.io/formbricks/hub"` | |
|
||||
| hub.image.tag | string | `"0.3.0"` | Fallback when digest is empty. |
|
||||
| hub.migration.activeDeadlineSeconds | int | `900` | |
|
||||
| hub.migration.backoffLimit | int | `3` | |
|
||||
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| hub.pdb.enabled | bool | `false` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
| hub.resources.requests.cpu | string | `"100m"` | |
|
||||
| hub.resources.requests.memory | string | `"256Mi"` | |
|
||||
| hub.worker.autoscaling.enabled | bool | `false` | |
|
||||
| hub.worker.autoscaling.maxReplicas | int | `5` | |
|
||||
| hub.worker.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.worker.enabled | bool | `true` | |
|
||||
| hub.worker.env | object | `{}` | |
|
||||
| hub.worker.pdb.enabled | bool | `false` | |
|
||||
| hub.worker.replicas | int | `1` | |
|
||||
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
|
||||
| hub.worker.resources.requests.cpu | string | `"100m"` | |
|
||||
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
|
||||
| hub.worker.waitForApi.enabled | bool | `true` | |
|
||||
| hub.worker.waitForApi.maxAttempts | int | `120` | 120 attempts at 5s intervals = 10 minutes. |
|
||||
| ingress.annotations | object | `{}` | |
|
||||
| ingress.enabled | bool | `false` | |
|
||||
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
|
||||
| ingress.hosts[0].paths[0].path | string | `"/"` | |
|
||||
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
|
||||
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
|
||||
| ingress.ingressClassName | string | `"alb"` | |
|
||||
| migration.annotations | object | `{}` | |
|
||||
| migration.backoffLimit | int | `3` | |
|
||||
| migration.enabled | bool | `true` | |
|
||||
| migration.resources.limits.memory | string | `"512Mi"` | |
|
||||
| migration.resources.requests.cpu | string | `"100m"` | |
|
||||
| migration.resources.requests.memory | string | `"256Mi"` | |
|
||||
| migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| nameOverride | string | `""` | |
|
||||
| partOfOverride | string | `""` | |
|
||||
| pdb.additionalLabels | object | `{}` | |
|
||||
| pdb.annotations | object | `{}` | |
|
||||
| pdb.enabled | bool | `true` | |
|
||||
| pdb.minAvailable | int | `1` | |
|
||||
| postgresql.auth.database | string | `"formbricks"` | |
|
||||
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
|
||||
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
|
||||
| postgresql.auth.username | string | `"formbricks"` | |
|
||||
| postgresql.enabled | bool | `true` | |
|
||||
| postgresql.externalDatabaseUrl | string | `""` | |
|
||||
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
|
||||
| postgresql.global.security.allowInsecureImages | bool | `true` | |
|
||||
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
|
||||
| postgresql.image.tag | string | `"pg17"` | |
|
||||
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
|
||||
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
|
||||
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
|
||||
| postgresql.primary.persistence.enabled | bool | `true` | |
|
||||
| postgresql.primary.persistence.size | string | `"10Gi"` | |
|
||||
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
|
||||
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
|
||||
| rbac.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.additionalLabels | object | `{}` | |
|
||||
| rbac.serviceAccount.annotations | object | `{}` | |
|
||||
| rbac.serviceAccount.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.name | string | `""` | |
|
||||
| redis.architecture | string | `"standalone"` | |
|
||||
| redis.auth.enabled | bool | `true` | |
|
||||
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
|
||||
| redis.enabled | bool | `true` | |
|
||||
| redis.externalRedisUrl | string | `""` | |
|
||||
| redis.fullnameOverride | string | `"formbricks-redis"` | |
|
||||
| redis.master.persistence.enabled | bool | `true` | |
|
||||
| redis.networkPolicy.enabled | bool | `false` | |
|
||||
| secret.enabled | bool | `true` | |
|
||||
| service.additionalLabels | object | `{}` | |
|
||||
| service.annotations | object | `{}` | |
|
||||
| service.enabled | bool | `true` | |
|
||||
| service.ports | list | `[]` | |
|
||||
| service.type | string | `"ClusterIP"` | |
|
||||
| serviceMonitor.additionalLabels | string | `nil` | |
|
||||
| serviceMonitor.annotations | string | `nil` | |
|
||||
| serviceMonitor.enabled | bool | `true` | |
|
||||
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
|
||||
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
|
||||
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| autoscaling.additionalLabels | object | `{}` | |
|
||||
| autoscaling.annotations | object | `{}` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].periodSeconds | int | `120` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].type | string | `"Pods"` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].value | int | `1` | |
|
||||
| autoscaling.behavior.scaleDown.stabilizationWindowSeconds | int | `300` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].periodSeconds | int | `60` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].type | string | `"Pods"` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].value | int | `2` | |
|
||||
| autoscaling.behavior.scaleUp.stabilizationWindowSeconds | int | `60` | |
|
||||
| autoscaling.enabled | bool | `true` | |
|
||||
| autoscaling.maxReplicas | int | `10` | |
|
||||
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
|
||||
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[0].type | string | `"Resource"` | |
|
||||
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
|
||||
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[1].type | string | `"Resource"` | |
|
||||
| autoscaling.minReplicas | int | `1` | |
|
||||
| componentOverride | string | `""` | |
|
||||
| deployment.additionalLabels | object | `{}` | |
|
||||
| deployment.additionalPodAnnotations | object | `{}` | |
|
||||
| deployment.additionalPodLabels | object | `{}` | |
|
||||
| deployment.affinity | object | `{}` | |
|
||||
| deployment.annotations | object | `{}` | |
|
||||
| deployment.args | list | `[]` | |
|
||||
| deployment.command | list | `[]` | |
|
||||
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
|
||||
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| deployment.env | object | `{}` | |
|
||||
| deployment.envFrom | string | `nil` | |
|
||||
| deployment.image.digest | string | `""` | |
|
||||
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
|
||||
| deployment.image.tag | string | `""` | |
|
||||
| deployment.imagePullSecrets | string | `""` | |
|
||||
| deployment.nodeSelector | object | `{}` | |
|
||||
| deployment.ports.http.containerPort | int | `3000` | |
|
||||
| deployment.ports.http.exposed | bool | `true` | |
|
||||
| deployment.ports.http.protocol | string | `"TCP"` | |
|
||||
| deployment.ports.metrics.containerPort | int | `9464` | |
|
||||
| deployment.ports.metrics.exposed | bool | `true` | |
|
||||
| deployment.ports.metrics.protocol | string | `"TCP"` | |
|
||||
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
|
||||
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
|
||||
| deployment.reloadOnChange | bool | `false` | |
|
||||
| deployment.replicas | int | `1` | |
|
||||
| deployment.resources.limits.memory | string | `"2Gi"` | |
|
||||
| deployment.resources.requests.cpu | string | `"1"` | |
|
||||
| deployment.resources.requests.memory | string | `"1Gi"` | |
|
||||
| deployment.revisionHistoryLimit | int | `2` | |
|
||||
| deployment.securityContext | object | `{}` | |
|
||||
| deployment.strategy.type | string | `"RollingUpdate"` | |
|
||||
| deployment.tolerations | list | `[]` | |
|
||||
| deployment.topologySpreadConstraints | list | `[]` | |
|
||||
| enterprise.enabled | bool | `false` | |
|
||||
| enterprise.licenseKey | string | `""` | |
|
||||
| externalSecret.enabled | bool | `false` | |
|
||||
| externalSecret.files | object | `{}` | |
|
||||
| externalSecret.refreshInterval | string | `"1h"` | |
|
||||
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
|
||||
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
|
||||
| formbricks.publicUrl | string | `""` | |
|
||||
| formbricks.webappUrl | string | `""` | |
|
||||
| hub.autoscaling.enabled | bool | `false` | |
|
||||
| hub.autoscaling.maxReplicas | int | `3` | |
|
||||
| hub.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.enabled | bool | `true` | |
|
||||
| hub.embeddings.auth.enabled | bool | `true` | |
|
||||
| hub.embeddings.auth.existingSecret | string | `""` | |
|
||||
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
|
||||
| hub.embeddings.autoscaling.enabled | bool | `false` | |
|
||||
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
|
||||
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
|
||||
| hub.embeddings.enabled | bool | `false` | |
|
||||
| hub.embeddings.extraArgs | list | `["--dtype","float16"]` | Additional args appended to the generated TEI args. |
|
||||
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
|
||||
| hub.embeddings.huggingFace.token | string | `""` | |
|
||||
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
|
||||
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
|
||||
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
|
||||
| hub.embeddings.maxConcurrent | string | `"5"` | |
|
||||
| hub.embeddings.model | string | `"Alibaba-NLP/gte-multilingual-base"` | |
|
||||
| hub.embeddings.persistence.enabled | bool | `true` | |
|
||||
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
|
||||
| hub.embeddings.persistence.size | string | `"10Gi"` | |
|
||||
| hub.embeddings.pdb.enabled | bool | `false` | |
|
||||
| hub.embeddings.port | int | `8080` | |
|
||||
| hub.embeddings.prometheusPort | int | `9000` | |
|
||||
| hub.embeddings.replicas | int | `1` | |
|
||||
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
|
||||
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
|
||||
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
|
||||
| hub.embeddings.runtime | string | `"tei"` | |
|
||||
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
|
||||
| hub.embeddings.service.port | int | `8080` | |
|
||||
| hub.embeddings.service.type | string | `"ClusterIP"` | |
|
||||
| hub.env | object | `{}` | |
|
||||
| hub.existingSecret | string | `""` | |
|
||||
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
|
||||
| hub.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| hub.image.repository | string | `"ghcr.io/formbricks/hub"` | |
|
||||
| hub.image.tag | string | `"0.3.0"` | Fallback when digest is empty. |
|
||||
| hub.migration.activeDeadlineSeconds | int | `900` | |
|
||||
| hub.migration.backoffLimit | int | `3` | |
|
||||
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| hub.pdb.enabled | bool | `false` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
| hub.resources.requests.cpu | string | `"100m"` | |
|
||||
| hub.resources.requests.memory | string | `"256Mi"` | |
|
||||
| hub.worker.autoscaling.enabled | bool | `false` | |
|
||||
| hub.worker.autoscaling.maxReplicas | int | `5` | |
|
||||
| hub.worker.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.worker.enabled | bool | `true` | |
|
||||
| hub.worker.env | object | `{}` | |
|
||||
| hub.worker.pdb.enabled | bool | `false` | |
|
||||
| hub.worker.replicas | int | `1` | |
|
||||
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
|
||||
| hub.worker.resources.requests.cpu | string | `"100m"` | |
|
||||
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
|
||||
| hub.worker.waitForApi.enabled | bool | `true` | |
|
||||
| hub.worker.waitForApi.maxAttempts | int | `120` | 120 attempts at 5s intervals = 10 minutes. |
|
||||
| ingress.annotations | object | `{}` | |
|
||||
| ingress.enabled | bool | `false` | |
|
||||
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
|
||||
| ingress.hosts[0].paths[0].path | string | `"/"` | |
|
||||
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
|
||||
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
|
||||
| ingress.ingressClassName | string | `"alb"` | |
|
||||
| migration.annotations | object | `{}` | |
|
||||
| migration.backoffLimit | int | `3` | |
|
||||
| migration.enabled | bool | `true` | |
|
||||
| migration.resources.limits.memory | string | `"512Mi"` | |
|
||||
| migration.resources.requests.cpu | string | `"100m"` | |
|
||||
| migration.resources.requests.memory | string | `"256Mi"` | |
|
||||
| migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| nameOverride | string | `""` | |
|
||||
| partOfOverride | string | `""` | |
|
||||
| pdb.additionalLabels | object | `{}` | |
|
||||
| pdb.annotations | object | `{}` | |
|
||||
| pdb.enabled | bool | `true` | |
|
||||
| pdb.minAvailable | int | `1` | |
|
||||
| postgresql.auth.database | string | `"formbricks"` | |
|
||||
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
|
||||
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
|
||||
| postgresql.auth.username | string | `"formbricks"` | |
|
||||
| postgresql.enabled | bool | `true` | |
|
||||
| postgresql.externalDatabaseUrl | string | `""` | |
|
||||
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
|
||||
| postgresql.global.security.allowInsecureImages | bool | `true` | |
|
||||
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
|
||||
| postgresql.image.tag | string | `"pg17"` | |
|
||||
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
|
||||
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
|
||||
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
|
||||
| postgresql.primary.persistence.enabled | bool | `true` | |
|
||||
| postgresql.primary.persistence.size | string | `"10Gi"` | |
|
||||
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
|
||||
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
|
||||
| rbac.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.additionalLabels | object | `{}` | |
|
||||
| rbac.serviceAccount.annotations | object | `{}` | |
|
||||
| rbac.serviceAccount.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.name | string | `""` | |
|
||||
| redis.architecture | string | `"standalone"` | |
|
||||
| redis.auth.enabled | bool | `true` | |
|
||||
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
|
||||
| redis.enabled | bool | `true` | |
|
||||
| redis.externalRedisUrl | string | `""` | |
|
||||
| redis.fullnameOverride | string | `"formbricks-redis"` | |
|
||||
| redis.master.persistence.enabled | bool | `true` | |
|
||||
| redis.networkPolicy.enabled | bool | `false` | |
|
||||
| secret.enabled | bool | `true` | |
|
||||
| service.additionalLabels | object | `{}` | |
|
||||
| service.annotations | object | `{}` | |
|
||||
| service.enabled | bool | `true` | |
|
||||
| service.ports | list | `[]` | |
|
||||
| service.type | string | `"ClusterIP"` | |
|
||||
| serviceMonitor.additionalLabels | string | `nil` | |
|
||||
| serviceMonitor.annotations | string | `nil` | |
|
||||
| serviceMonitor.enabled | bool | `true` | |
|
||||
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
|
||||
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
|
||||
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
|
||||
|
||||
@@ -9,46 +9,120 @@ cube(`FeedbackRecords`, {
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
uniqueRespondents: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.user_id`,
|
||||
description: `Number of unique users who provided feedback`,
|
||||
},
|
||||
|
||||
uniqueResponses: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.submission_id`,
|
||||
description: `Number of unique survey submissions (a submission can produce multiple feedback records)`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
|
||||
description: `Number of NPS promoters (score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
|
||||
description: `Number of NPS detractors (score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
|
||||
description: `Number of NPS passives (score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
(COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6 THEN 1 END)::numeric)
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
|
||||
},
|
||||
|
||||
averageScore: {
|
||||
npsAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
description: `Average NPS score`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps'` }],
|
||||
description: `Average NPS rating (0-10)`,
|
||||
},
|
||||
|
||||
csatCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CSAT responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
csatSatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4` }],
|
||||
description: `Number of satisfied CSAT responses (top-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatDissatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number BETWEEN 1 AND 2` }],
|
||||
description: `Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatNeutralCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number = 3` }],
|
||||
description: `Number of neutral CSAT responses (middle box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
ELSE ROUND(
|
||||
(
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4 THEN 1 END)::numeric
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `CSAT Score: % of answered CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale). NULL when there are no answered CSAT responses.`,
|
||||
},
|
||||
|
||||
csatAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat'` }],
|
||||
description: `Average CSAT rating (1-5)`,
|
||||
},
|
||||
|
||||
cesCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CES responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
cesAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces'` }],
|
||||
description: `Average CES rating (scale is 1-5 or 1-7 depending on the question)`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -77,22 +151,70 @@ cube(`FeedbackRecords`, {
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
fieldLabel: {
|
||||
sql: `field_label`,
|
||||
type: `string`,
|
||||
description: `Human-readable label of the question/field (e.g., "How satisfied are you with support?")`,
|
||||
},
|
||||
|
||||
fieldGroupLabel: {
|
||||
sql: `field_group_label`,
|
||||
type: `string`,
|
||||
description: `Label of the parent composite question for matrix/ranking rows`,
|
||||
},
|
||||
|
||||
language: {
|
||||
sql: `language`,
|
||||
type: `string`,
|
||||
description: `Response language code (e.g., "en", "de"). NULL when language is "default".`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
npsValue: {
|
||||
createdAt: {
|
||||
sql: `created_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was created in Hub`,
|
||||
},
|
||||
|
||||
updatedAt: {
|
||||
sql: `updated_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was last updated in Hub`,
|
||||
},
|
||||
|
||||
valueNumber: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
description: `Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, generic number). Pair with a fieldType filter to keep scales consistent.`,
|
||||
},
|
||||
|
||||
valueText: {
|
||||
sql: `value_text`,
|
||||
type: `string`,
|
||||
description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`,
|
||||
},
|
||||
|
||||
valueBoolean: {
|
||||
sql: `value_boolean`,
|
||||
type: `boolean`,
|
||||
description: `Boolean answer value (yes/no questions). Pair with a fieldType filter.`,
|
||||
},
|
||||
|
||||
valueDate: {
|
||||
sql: `value_date`,
|
||||
type: `time`,
|
||||
description: `Date answer value (e.g., "preferred meeting date"). Pair with a fieldType filter.`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
sql: `submission_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
|
||||
},
|
||||
|
||||
userId: {
|
||||
|
||||
@@ -92,6 +92,26 @@ This function allows rendering values dynamically.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Render a Kubernetes EnvVar from chart env maps.
|
||||
Scalar values become quoted string values. Map values are rendered as EnvVar fields,
|
||||
which keeps advanced forms such as valueFrom supported.
|
||||
*/}}
|
||||
{{- define "formbricks.envVarValue" -}}
|
||||
{{- $value := .value -}}
|
||||
{{- if kindIs "map" $value -}}
|
||||
{{- include "formbricks.tplvalues.render" (dict "value" $value "context" .context) -}}
|
||||
{{- else if kindIs "invalid" $value -}}
|
||||
value: ""
|
||||
{{- else -}}
|
||||
value: {{ include "formbricks.tplvalues.render" (dict "value" (toString $value) "context" .context) | trim | quote }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.envVar" -}}
|
||||
- name: {{ include "formbricks.tplvalues.render" (dict "value" .name "context" .context) }}
|
||||
{{- include "formbricks.envVarValue" (dict "value" .value "context" .context) | nindent 2 }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Allow the release namespace to be overridden.
|
||||
@@ -289,6 +309,15 @@ true
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.cubejsApiSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
|
||||
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- define "formbricks.envoy.gatewayClassName" -}}
|
||||
{{- if .Values.envoy.formbricks.gatewayClass.name -}}
|
||||
{{- .Values.envoy.formbricks.gatewayClass.name | trunc 63 | trimSuffix "-" -}}
|
||||
|
||||
@@ -70,8 +70,12 @@ spec:
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.cube.envFrom }}
|
||||
{{- if or .Values.cube.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
|
||||
envFrom:
|
||||
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
|
||||
- secretRef:
|
||||
name: {{ template "formbricks.name" . }}-app-secrets
|
||||
{{- end }}
|
||||
{{- range $value := .Values.cube.envFrom }}
|
||||
{{- if (eq .type "configmap") }}
|
||||
- configMapRef:
|
||||
@@ -97,12 +101,7 @@ spec:
|
||||
{{- end }}
|
||||
env:
|
||||
{{- range $key, $value := .Values.cube.env }}
|
||||
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
|
||||
{{- else }}
|
||||
{{- toYaml $value | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: cube-config
|
||||
|
||||
@@ -136,12 +136,7 @@ spec:
|
||||
value: "http://{{ include "formbricks.hubname" . }}:8080"
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.deployment.env }}
|
||||
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
|
||||
{{- else }}
|
||||
{{- toYaml $value | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.deployment.resources }}
|
||||
resources:
|
||||
|
||||
@@ -73,8 +73,7 @@ spec:
|
||||
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" .Values.hub.env) | nindent 12 }}
|
||||
{{- range $key, $value := .Values.hub.env }}
|
||||
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.hub.resources }}
|
||||
|
||||
@@ -129,8 +129,7 @@ spec:
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.hub.embeddings.env }}
|
||||
{{- if not (or (and $.Values.hub.embeddings.auth.enabled (eq $key "API_KEY")) (and (or $.Values.hub.embeddings.huggingFace.existingSecret $.Values.hub.embeddings.huggingFace.token) (eq $key "HF_TOKEN"))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -90,14 +90,12 @@ spec:
|
||||
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $workerEnv) | nindent 12 }}
|
||||
{{- range $key, $value := .Values.hub.env }}
|
||||
{{- if and (not (hasKey $.Values.hub.worker.env $key)) (not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key)))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.hub.worker.env }}
|
||||
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -82,12 +82,7 @@ spec:
|
||||
{{- end }}
|
||||
env:
|
||||
{{- range $key, $value := .Values.deployment.env }}
|
||||
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
|
||||
{{- else }}
|
||||
{{- toYaml $value | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.migration.resources }}
|
||||
resources:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{{- $redisPassword := include "formbricks.redisPassword" . }}
|
||||
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
|
||||
{{- $hubApiKey := include "formbricks.hubApiKey" . }}
|
||||
{{- $cubejsApiSecret := include "formbricks.cubejsApiSecret" . }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -31,6 +32,7 @@ data:
|
||||
{{- end }}
|
||||
HUB_API_KEY: {{ $hubApiKey | b64enc }}
|
||||
credential: {{ printf "Bearer %s" $hubApiKey | b64enc }}
|
||||
CUBEJS_API_SECRET: {{ $cubejsApiSecret | b64enc }}
|
||||
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
|
||||
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
|
||||
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
|
||||
|
||||
@@ -580,8 +580,9 @@ cube:
|
||||
type: ClusterIP
|
||||
port: 4000
|
||||
|
||||
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
|
||||
# through envFrom or another secret-management flow.
|
||||
# The generated app secret supplies CUBEJS_API_SECRET when secret.enabled=true.
|
||||
# Secret values such as CUBEJS_DB_* should be supplied through envFrom or another secret-management
|
||||
# flow.
|
||||
envFrom: []
|
||||
|
||||
env:
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ That's it! After running the command and providing the required information, vis
|
||||
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and the bundled Cube service. Hub and Cube share the same database as Formbricks by default and both start as part of the baseline `docker compose up`.
|
||||
|
||||
- **Migrations**: A `hub-migrate` service runs Hub's database migrations (goose + river) before the Hub API starts. It runs on every `docker compose up` and is idempotent.
|
||||
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` and `CUBEJS_API_SECRET` (both required). `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the compose network. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
|
||||
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` and `CUBEJS_API_SECRET` (both required). Run `docker compose config >/dev/null` after creating `.env`; it fails if either value is missing or empty. `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the compose network. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
|
||||
- **Development** (`docker-compose.dev.yml`): Hub uses a dedicated local `hub` database and `HUB_API_KEY` defaults to `dev-api-key`. The dev stack starts `hub` plus `hub-worker`; set `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, and any provider credentials in the repo root `.env` to enable Hub embeddings locally. See the [Hub embeddings environment reference](https://hub.formbricks.com/reference/environment-variables/#embeddings) for provider-specific values. Cube starts with the dev stack, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub`, `hub-worker`, and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
|
||||
|
||||
In development, Hub is exposed locally on port **8080** and Cube on **4000** (with the Cube playground on **4001**). In production Docker Compose, both stay internal to the compose network at `http://hub:8080` and `http://cube:4000`.
|
||||
|
||||
@@ -40,7 +40,7 @@ x-environment: &environment
|
||||
|
||||
# Cube semantic-layer API used by Formbricks analytics. Required.
|
||||
CUBEJS_API_URL: ${CUBEJS_API_URL:-http://cube:4000}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube}
|
||||
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
|
||||
CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube}
|
||||
|
||||
@@ -312,7 +312,7 @@ services:
|
||||
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
|
||||
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
|
||||
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube}
|
||||
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
|
||||
CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube}
|
||||
CUBEJS_DEFAULT_API_SCOPES: meta,data
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# V3 API — Surveys (hand-maintained; not generated by generate-api-specs).
|
||||
# Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts
|
||||
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
|
||||
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Formbricks API v3
|
||||
description: |
|
||||
**GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
|
||||
**GET /api/v3/surveys**, **GET /api/v3/surveys/{surveyId}**, and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
|
||||
|
||||
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
|
||||
|
||||
**workspaceId**
|
||||
Query param `workspaceId` is the canonical container identifier for this API.
|
||||
**Deprecated compatibility:** the older `environmentId` identifier is still accepted
|
||||
for compatibility but should no longer be used in new integrations.
|
||||
|
||||
**Auth**
|
||||
Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation.
|
||||
@@ -34,7 +31,7 @@ info:
|
||||
The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`.
|
||||
|
||||
**Next steps (out of scope for this spec)**
|
||||
Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
|
||||
Additional v3 survey write endpoints, optional ETag/304, field selection, and survey version history.
|
||||
version: 0.1.0
|
||||
x-implementation-notes:
|
||||
route: apps/web/app/api/v3/surveys/route.ts
|
||||
@@ -51,7 +48,6 @@ paths:
|
||||
summary: List surveys
|
||||
description: |
|
||||
Returns surveys for the workspace. Session cookie or x-api-key.
|
||||
Note: Environments are deprecated. Use workspace/workspaceId terminology.
|
||||
tags:
|
||||
- V3 Surveys
|
||||
parameters:
|
||||
@@ -63,15 +59,6 @@ paths:
|
||||
format: cuid2
|
||||
description: |
|
||||
Workspace identifier. This is the canonical container ID for v3 APIs.
|
||||
- in: query
|
||||
name: environmentId
|
||||
required: false
|
||||
deprecated: true
|
||||
schema:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: |
|
||||
Deprecated: use `workspaceId`. This alias is retained for backward compatibility.
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
@@ -198,6 +185,177 @@ paths:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
/api/v3/surveys/{surveyId}:
|
||||
get:
|
||||
operationId: getSurveyV3
|
||||
summary: Retrieve a survey
|
||||
description: |
|
||||
Returns the public v3 survey management resource for one survey. By default, translatable
|
||||
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
|
||||
to filter those maps to one or more requested locale codes.
|
||||
tags:
|
||||
- V3 Surveys
|
||||
parameters:
|
||||
- in: path
|
||||
name: surveyId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Survey identifier.
|
||||
- in: query
|
||||
name: lang
|
||||
required: false
|
||||
style: form
|
||||
explode: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: "^[a-z]{2}(-[A-Z][a-z]{3})?-[A-Z]{2}$"
|
||||
examples:
|
||||
- [de-DE]
|
||||
- [de-DE, pt-PT]
|
||||
- [zh-Hans-CN]
|
||||
description: |
|
||||
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
|
||||
The response shape stays stable: translatable fields are always maps keyed by locale code, never
|
||||
strings. Send canonical locale codes (`de-DE`, `zh-Hans-CN`, not `de`). For interoperability, the
|
||||
parser is case-insensitive, accepts `_` or `-` separators, and normalizes accepted region-qualified
|
||||
tags (`de_DE`, `DE-de` → `de-DE`; `zh_hans_cn` → `zh-Hans-CN`). Language-only selectors (`de`) and
|
||||
aliases are not accepted.
|
||||
Disabled-but-configured languages are readable in the management API so unfinished translations can
|
||||
be completed.
|
||||
responses:
|
||||
"200":
|
||||
description: Survey retrieved successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
description: Request correlation ID
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: "private, no-store"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/SurveyResource"
|
||||
examples:
|
||||
canonical:
|
||||
summary: Canonical multilingual authoring resource
|
||||
value:
|
||||
data:
|
||||
id: clseedsurveycsat000000
|
||||
workspaceId: clseedworkspace000000000
|
||||
createdAt: "2026-05-18T09:24:54.014Z"
|
||||
updatedAt: "2026-05-18T09:24:54.014Z"
|
||||
name: CSAT Survey
|
||||
type: link
|
||||
status: inProgress
|
||||
metadata: {}
|
||||
defaultLanguage: en-US
|
||||
languages:
|
||||
- code: en-US
|
||||
default: true
|
||||
enabled: true
|
||||
- code: de-DE
|
||||
default: false
|
||||
enabled: false
|
||||
welcomeCard:
|
||||
enabled: false
|
||||
blocks:
|
||||
- id: e0tfwzqk63op37y14z95qq3k
|
||||
name: Main Block
|
||||
elements:
|
||||
- id: nzte4cm8836hgjw63pesziht
|
||||
type: rating
|
||||
range: 5
|
||||
scale: smiley
|
||||
headline:
|
||||
en-US: How satisfied are you with our product?
|
||||
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
|
||||
required: true
|
||||
endings: []
|
||||
hiddenFields:
|
||||
enabled: false
|
||||
variables: []
|
||||
filtered:
|
||||
summary: Language-filtered projection with ?lang=de-DE
|
||||
value:
|
||||
data:
|
||||
id: clseedsurveycsat000000
|
||||
workspaceId: clseedworkspace000000000
|
||||
createdAt: "2026-05-18T09:24:54.014Z"
|
||||
updatedAt: "2026-05-18T09:24:54.014Z"
|
||||
name: CSAT Survey
|
||||
type: link
|
||||
status: inProgress
|
||||
metadata: {}
|
||||
defaultLanguage: en-US
|
||||
languages:
|
||||
- code: en-US
|
||||
default: true
|
||||
enabled: true
|
||||
- code: de-DE
|
||||
default: false
|
||||
enabled: false
|
||||
welcomeCard:
|
||||
enabled: false
|
||||
blocks:
|
||||
- id: e0tfwzqk63op37y14z95qq3k
|
||||
name: Main Block
|
||||
elements:
|
||||
- id: nzte4cm8836hgjw63pesziht
|
||||
type: rating
|
||||
range: 5
|
||||
scale: smiley
|
||||
headline:
|
||||
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
|
||||
required: true
|
||||
endings: []
|
||||
hiddenFields:
|
||||
enabled: false
|
||||
variables: []
|
||||
"400":
|
||||
description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"401":
|
||||
description: Not authenticated (no valid session or API key)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"403":
|
||||
description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: { type: integer }
|
||||
description: Seconds until the current rate-limit window resets
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
delete:
|
||||
operationId: deleteSurveyV3
|
||||
summary: Delete a survey
|
||||
@@ -213,7 +371,7 @@ paths:
|
||||
format: cuid2
|
||||
description: Survey identifier.
|
||||
responses:
|
||||
"200":
|
||||
"204":
|
||||
description: Survey deleted successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
@@ -222,10 +380,6 @@ paths:
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: "private, no-store"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SurveyDeleteResponse"
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
@@ -304,15 +458,694 @@ components:
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
isEncrypted: { type: boolean }
|
||||
SurveyDeleteResponse:
|
||||
TranslatableText:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/TranslatableTextMap"
|
||||
description: |
|
||||
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
|
||||
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
|
||||
The internal storage key `default` is never exposed by v3.
|
||||
examples:
|
||||
- en-US: What should we improve?
|
||||
de-DE: Was sollten wir verbessern?
|
||||
TranslatableTextMap:
|
||||
type: object
|
||||
required: [data]
|
||||
description: Canonical multilingual text map keyed by real locale codes.
|
||||
propertyNames:
|
||||
type: string
|
||||
description: BCP 47 locale code, for example `en-US`, `de-DE`, or `pt-BR`.
|
||||
additionalProperties:
|
||||
type: string
|
||||
SurveyLanguage:
|
||||
type: object
|
||||
description: |
|
||||
Language configured for this survey. Aliases/display names are intentionally not exposed in v3.
|
||||
Disabled languages can still be read by the management API so unfinished translations can be completed.
|
||||
required: [code, default, enabled]
|
||||
properties:
|
||||
data:
|
||||
code:
|
||||
type: string
|
||||
description: Canonical locale code.
|
||||
example: en-US
|
||||
default:
|
||||
type: boolean
|
||||
description: Whether this is the default authoring language.
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether this language is enabled for respondent-facing delivery.
|
||||
SurveyWelcomeCard:
|
||||
type: object
|
||||
description: Optional card shown before the first survey block.
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
headline:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
fileUrl:
|
||||
type: string
|
||||
videoUrl:
|
||||
type: string
|
||||
timeToFinish:
|
||||
type: boolean
|
||||
showResponseCount:
|
||||
type: boolean
|
||||
additionalProperties: true
|
||||
SurveyHiddenFields:
|
||||
type: object
|
||||
description: |
|
||||
Hidden fields, sometimes called embedded data in other survey products. Field ids are stable
|
||||
public identifiers and may be referenced by logic, recall, quotas, integrations, and response data.
|
||||
Use only letters, numbers, underscores, and hyphens; avoid spaces and reserved ids.
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
fieldIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: "^[a-zA-Z0-9_-]+$"
|
||||
uniqueItems: true
|
||||
additionalProperties: false
|
||||
SurveyVariable:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyNumberVariable"
|
||||
- $ref: "#/components/schemas/SurveyTextVariable"
|
||||
description: |
|
||||
Survey variable. Variable ids are stable references used by logic and calculation actions.
|
||||
Variable names are human-readable labels and must be unique within the survey.
|
||||
SurveyNumberVariable:
|
||||
type: object
|
||||
description: |
|
||||
Number variable. Used by `calculate` logic actions with numeric operators such as `add`,
|
||||
`subtract`, `multiply`, `divide`, or `assign`.
|
||||
required: [id, name, type, value]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable variable id referenced from logic.
|
||||
name:
|
||||
type: string
|
||||
pattern: "^[a-z0-9_]+$"
|
||||
description: Unique variable name. Lowercase letters, numbers, and underscores only.
|
||||
type:
|
||||
type: string
|
||||
enum: [number]
|
||||
value:
|
||||
type: number
|
||||
description: Default numeric value.
|
||||
additionalProperties: false
|
||||
SurveyTextVariable:
|
||||
type: object
|
||||
description: |
|
||||
Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`.
|
||||
required: [id, name, type, value]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable variable id referenced from logic.
|
||||
name:
|
||||
type: string
|
||||
pattern: "^[a-z0-9_]+$"
|
||||
description: Unique variable name. Lowercase letters, numbers, and underscores only.
|
||||
type:
|
||||
type: string
|
||||
enum: [text]
|
||||
value:
|
||||
type: string
|
||||
description: Default text value.
|
||||
additionalProperties: false
|
||||
SurveyEnding:
|
||||
type: object
|
||||
description: Ending reached after the last block or a jump action.
|
||||
required: [id, type]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable ending id. `jumpToBlock.target` may point to this id.
|
||||
type:
|
||||
type: string
|
||||
enum: [endScreen, redirectToUrl]
|
||||
headline:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
buttonLink:
|
||||
type: string
|
||||
imageUrl:
|
||||
type: string
|
||||
videoUrl:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
description: Redirect URL for `redirectToUrl` endings.
|
||||
label:
|
||||
type: string
|
||||
description: Optional internal label for redirect endings.
|
||||
additionalProperties: true
|
||||
SurveyBlock:
|
||||
type: object
|
||||
description: |
|
||||
Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can
|
||||
jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally
|
||||
creating/deleting a block.
|
||||
required: [id, name, elements]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable block id.
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
elements:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyElement"
|
||||
logic:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyBlockLogic"
|
||||
logicFallback:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Block or ending id used when no logic condition matches.
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
backButtonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
additionalProperties: true
|
||||
SurveyElement:
|
||||
type: object
|
||||
description: |
|
||||
Survey element/question inside a block. Element ids are stable public identifiers used by
|
||||
logic, recall strings, response data, quotas, integrations, and analysis. The schema lists
|
||||
the fields used by all current element types; type-specific fields are present only when relevant.
|
||||
required: [id, type, headline, required]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
pattern: "^[a-zA-Z0-9_-]+$"
|
||||
description: Stable element id. Avoid spaces and reserved ids.
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- openText
|
||||
- multipleChoiceSingle
|
||||
- multipleChoiceMulti
|
||||
- nps
|
||||
- rating
|
||||
- csat
|
||||
- ces
|
||||
- consent
|
||||
- pictureSelection
|
||||
- cta
|
||||
- date
|
||||
- fileUpload
|
||||
- cal
|
||||
- matrix
|
||||
- address
|
||||
- ranking
|
||||
- contactInfo
|
||||
headline:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
required:
|
||||
type: boolean
|
||||
imageUrl:
|
||||
type: string
|
||||
videoUrl:
|
||||
type: string
|
||||
isDraft:
|
||||
type: boolean
|
||||
description: Draft marker used by the editor and future update rules.
|
||||
placeholder:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
longAnswer:
|
||||
type: boolean
|
||||
description: "`openText` only."
|
||||
inputType:
|
||||
type: string
|
||||
enum: [text, email, url, number, phone]
|
||||
description: "`openText` only."
|
||||
charLimit:
|
||||
type: object
|
||||
required: [id]
|
||||
description: "`openText` character limit configuration."
|
||||
properties:
|
||||
id: { type: string }
|
||||
enabled:
|
||||
type: boolean
|
||||
min:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
additionalProperties: false
|
||||
choices:
|
||||
type: array
|
||||
description: Choice list for multiple choice, ranking, and picture selection elements.
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyChoice"
|
||||
- $ref: "#/components/schemas/SurveyPictureChoice"
|
||||
shuffleOption:
|
||||
type: string
|
||||
enum: [none, all, exceptLast, reverseOrderOccasionally, reverseOrderExceptLast]
|
||||
displayType:
|
||||
type: string
|
||||
enum: [list, dropdown]
|
||||
description: Multiple choice display style.
|
||||
otherOptionPlaceholder:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
lowerLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
upperLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
isColorCodingEnabled:
|
||||
type: boolean
|
||||
scale:
|
||||
type: string
|
||||
enum: [number, smiley, star]
|
||||
description: Rating, CSAT, CES, or NPS scale display.
|
||||
range:
|
||||
type: integer
|
||||
enum: [3, 4, 5, 6, 7, 10]
|
||||
description: Rating range. CSAT is always 5; CES is 5 or 7.
|
||||
label:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
description: Consent checkbox label.
|
||||
allowMulti:
|
||||
type: boolean
|
||||
description: "`pictureSelection` only."
|
||||
buttonExternal:
|
||||
type: boolean
|
||||
description: "`cta` only."
|
||||
buttonUrl:
|
||||
type: string
|
||||
description: "`cta` only."
|
||||
ctaButtonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
html:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
description: "`date` helper copy."
|
||||
format:
|
||||
type: string
|
||||
enum: [M-d-y, d-M-y, y-M-d]
|
||||
description: "`date` only."
|
||||
allowMultipleFiles:
|
||||
type: boolean
|
||||
description: "`fileUpload` only."
|
||||
maxSizeInMB:
|
||||
type: number
|
||||
description: "`fileUpload` only."
|
||||
allowedFileExtensions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "`fileUpload` only."
|
||||
calUserName:
|
||||
type: string
|
||||
description: "`cal` only."
|
||||
calHost:
|
||||
type: string
|
||||
description: "`cal` only."
|
||||
rows:
|
||||
type: array
|
||||
description: Matrix rows.
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyChoice"
|
||||
columns:
|
||||
type: array
|
||||
description: Matrix columns.
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyChoice"
|
||||
addressLine1:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
addressLine2:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
city:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
state:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
zip:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
country:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
firstName:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
lastName:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
email:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
phone:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
company:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
validation:
|
||||
$ref: "#/components/schemas/SurveyValidation"
|
||||
additionalProperties: true
|
||||
SurveyChoice:
|
||||
type: object
|
||||
required: [id, label]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Stable choice id.
|
||||
label:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
additionalProperties: false
|
||||
SurveyPictureChoice:
|
||||
type: object
|
||||
required: [id, imageUrl]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Stable picture choice id.
|
||||
imageUrl:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
SurveyToggleInputConfig:
|
||||
type: object
|
||||
description: Field config for address and contact info elements.
|
||||
required: [show, required, placeholder]
|
||||
properties:
|
||||
show:
|
||||
type: boolean
|
||||
required:
|
||||
type: boolean
|
||||
placeholder:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
additionalProperties: false
|
||||
SurveyValidation:
|
||||
type: object
|
||||
description: Optional element-level validation rules.
|
||||
required: [rules]
|
||||
properties:
|
||||
logic:
|
||||
type: string
|
||||
enum: [and, or]
|
||||
default: and
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyValidationRule"
|
||||
additionalProperties: false
|
||||
SurveyValidationRule:
|
||||
type: object
|
||||
required: [id, type, params]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- minLength
|
||||
- maxLength
|
||||
- pattern
|
||||
- email
|
||||
- url
|
||||
- phone
|
||||
- equals
|
||||
- doesNotEqual
|
||||
- contains
|
||||
- doesNotContain
|
||||
- minValue
|
||||
- maxValue
|
||||
- isGreaterThan
|
||||
- isLessThan
|
||||
- minSelections
|
||||
- maxSelections
|
||||
- minRanked
|
||||
- rankAll
|
||||
- minRowsAnswered
|
||||
- answerAllRows
|
||||
- isLaterThan
|
||||
- isEarlierThan
|
||||
- isBetween
|
||||
- isNotBetween
|
||||
- fileExtensionIs
|
||||
- fileExtensionIsNot
|
||||
params:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
field:
|
||||
type: string
|
||||
enum:
|
||||
[
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
city,
|
||||
state,
|
||||
zip,
|
||||
country,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
company,
|
||||
]
|
||||
additionalProperties: false
|
||||
SurveyBlockLogic:
|
||||
type: object
|
||||
description: Conditional logic rule evaluated at block level.
|
||||
required: [id, conditions, actions]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
conditions:
|
||||
$ref: "#/components/schemas/SurveyConditionGroup"
|
||||
actions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyLogicAction"
|
||||
additionalProperties: false
|
||||
SurveyConditionGroup:
|
||||
type: object
|
||||
required: [id, connector, conditions]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
connector:
|
||||
type: string
|
||||
enum: [and, or]
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyCondition"
|
||||
- $ref: "#/components/schemas/SurveyConditionGroup"
|
||||
additionalProperties: false
|
||||
SurveyCondition:
|
||||
type: object
|
||||
description: |
|
||||
Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`,
|
||||
`isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do.
|
||||
required: [id, leftOperand, operator]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
leftOperand:
|
||||
$ref: "#/components/schemas/SurveyDynamicReference"
|
||||
operator:
|
||||
type: string
|
||||
enum:
|
||||
- equals
|
||||
- doesNotEqual
|
||||
- contains
|
||||
- doesNotContain
|
||||
- startsWith
|
||||
- doesNotStartWith
|
||||
- endsWith
|
||||
- doesNotEndWith
|
||||
- isSubmitted
|
||||
- isSkipped
|
||||
- isGreaterThan
|
||||
- isLessThan
|
||||
- isGreaterThanOrEqual
|
||||
- isLessThanOrEqual
|
||||
- equalsOneOf
|
||||
- includesAllOf
|
||||
- includesOneOf
|
||||
- doesNotIncludeOneOf
|
||||
- doesNotIncludeAllOf
|
||||
- isClicked
|
||||
- isNotClicked
|
||||
- isAccepted
|
||||
- isBefore
|
||||
- isAfter
|
||||
- isBooked
|
||||
- isPartiallySubmitted
|
||||
- isCompletelySubmitted
|
||||
- isSet
|
||||
- isNotSet
|
||||
- isEmpty
|
||||
- isNotEmpty
|
||||
- isAnyOf
|
||||
rightOperand:
|
||||
$ref: "#/components/schemas/SurveyLogicOperand"
|
||||
additionalProperties: false
|
||||
SurveyLogicOperand:
|
||||
oneOf:
|
||||
- type: object
|
||||
required: [type, value]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [static]
|
||||
value:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
- $ref: "#/components/schemas/SurveyDynamicReference"
|
||||
SurveyDynamicReference:
|
||||
type: object
|
||||
description: Dynamic reference to another value in the survey document.
|
||||
required: [type, value]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [element, variable, hiddenField]
|
||||
value:
|
||||
type: string
|
||||
description: Element id, variable id, or hidden field id depending on `type`.
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
SurveyLogicAction:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyCalculateAction"
|
||||
- $ref: "#/components/schemas/SurveyRequireAnswerAction"
|
||||
- $ref: "#/components/schemas/SurveyJumpToBlockAction"
|
||||
description: |
|
||||
Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id,
|
||||
`requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id
|
||||
or ending id.
|
||||
SurveyCalculateAction:
|
||||
type: object
|
||||
description: Updates a survey variable when the logic rule matches.
|
||||
required: [id, objective, variableId, operator, value]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
objective:
|
||||
type: string
|
||||
enum: [calculate]
|
||||
variableId:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Variable id for `calculate`.
|
||||
operator:
|
||||
type: string
|
||||
enum: [assign, concat, add, subtract, multiply, divide]
|
||||
value:
|
||||
$ref: "#/components/schemas/SurveyLogicOperand"
|
||||
additionalProperties: false
|
||||
SurveyRequireAnswerAction:
|
||||
type: object
|
||||
description: Requires an element/question to be answered before continuing.
|
||||
required: [id, objective, target]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
objective:
|
||||
type: string
|
||||
enum: [requireAnswer]
|
||||
target:
|
||||
type: string
|
||||
description: Target element id.
|
||||
additionalProperties: false
|
||||
SurveyJumpToBlockAction:
|
||||
type: object
|
||||
description: Jumps to another block or ending when the logic rule matches.
|
||||
required: [id, objective, target]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
objective:
|
||||
type: string
|
||||
enum: [jumpToBlock]
|
||||
target:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Target block id or ending id.
|
||||
additionalProperties: false
|
||||
SurveyResource:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- workspaceId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- name
|
||||
- type
|
||||
- status
|
||||
- metadata
|
||||
- defaultLanguage
|
||||
- languages
|
||||
- welcomeCard
|
||||
- blocks
|
||||
- endings
|
||||
- hiddenFields
|
||||
- variables
|
||||
properties:
|
||||
id: { type: string }
|
||||
workspaceId: { type: string }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
name: { type: string }
|
||||
type: { type: string, enum: [link, app, website, web] }
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
metadata:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
defaultLanguage:
|
||||
type: string
|
||||
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyLanguage"
|
||||
welcomeCard:
|
||||
$ref: "#/components/schemas/SurveyWelcomeCard"
|
||||
blocks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyBlock"
|
||||
endings:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyEnding"
|
||||
hiddenFields:
|
||||
$ref: "#/components/schemas/SurveyHiddenFields"
|
||||
variables:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyVariable"
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
|
||||
@@ -335,3 +1168,6 @@ components:
|
||||
properties:
|
||||
name: { type: string }
|
||||
reason: { type: string }
|
||||
identifier:
|
||||
type: string
|
||||
description: Optional canonical identifier related to the invalid value, such as a normalized locale code.
|
||||
|
||||
@@ -18,8 +18,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
<Info>
|
||||
Starting with Formbricks v5, the production Docker Compose stack includes Formbricks Hub and Cube as part of
|
||||
the baseline. Generate `HUB_API_KEY` and `CUBEJS_API_SECRET` during setup, keep `HUB_API_URL` at its
|
||||
internal default unless Hub runs elsewhere, and use the [migration guide](/self-hosting/advanced/migration#v5)
|
||||
when upgrading an existing 4.x instance.
|
||||
internal default unless Hub runs elsewhere, and use the [migration
|
||||
guide](/self-hosting/advanced/migration#v5) when upgrading an existing 4.x instance.
|
||||
</Info>
|
||||
|
||||
## Start
|
||||
@@ -56,6 +56,16 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
EOF
|
||||
```
|
||||
|
||||
1. **Validate the Docker Compose Configuration**
|
||||
|
||||
Confirm Docker Compose can resolve the required Hub and Cube variables before starting the stack:
|
||||
|
||||
```bash
|
||||
docker compose config >/dev/null
|
||||
```
|
||||
|
||||
This command fails if `HUB_API_KEY` or `CUBEJS_API_SECRET` is missing or empty in `.env`.
|
||||
|
||||
1. **Generate NextAuth Secret**
|
||||
|
||||
You need a NextAuth secret for session signing and encryption. Run one of the commands below based on your operating system:
|
||||
@@ -122,8 +132,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
|
||||
<Info>
|
||||
The bundled production stack already sets <code>HUB_API_URL</code> to <code>http://hub:8080</code>. Only
|
||||
change that value if your Formbricks app needs to reach Hub at a different address. If your deployment also
|
||||
resolves Compose variables from a shell environment or <code>.env</code> file, keep the same
|
||||
change that value if your Formbricks app needs to reach Hub at a different address. If your deployment
|
||||
also resolves Compose variables from a shell environment or <code>.env</code> file, keep the same
|
||||
<code>HUB_API_KEY</code> available there as well.
|
||||
</Info>
|
||||
|
||||
@@ -143,8 +153,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
Once the setup is running, open [**http://localhost:3000**](http://localhost:3000) in your browser to access Formbricks. The first time you visit, you'll see a setup wizard. Follow the steps to create your first user and start using Formbricks.
|
||||
|
||||
<Note>
|
||||
The bundled Docker stack keeps Formbricks Hub and Cube internal to the compose network. The app reaches
|
||||
them through `http://hub:8080` and `http://cube:4000`.
|
||||
The bundled Docker stack keeps Formbricks Hub and Cube internal to the compose network. The app reaches them
|
||||
through `http://hub:8080` and `http://cube:4000`.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
|
||||
@@ -116,7 +116,8 @@ Cube is part of the baseline Formbricks v5 stack and is bundled with the chart b
|
||||
|
||||
- set `cube.enabled: false` to skip the bundled Cube deployment
|
||||
- point the app at your external endpoint via `deployment.env.CUBEJS_API_URL`
|
||||
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom`
|
||||
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom` if you disable generated
|
||||
secrets
|
||||
|
||||
## 4. Upgrade The Deployment
|
||||
|
||||
@@ -134,8 +135,8 @@ For a Formbricks 4.x to 5.0 migration, confirm the following before running the
|
||||
- `HUB_API_KEY` is present
|
||||
- your edge rate-limiting plan is in place
|
||||
- any required `AI_*` variables are added
|
||||
- `CUBEJS_API_SECRET` is configured (Cube is bundled by default; provide an external endpoint if you set
|
||||
`cube.enabled: false`)
|
||||
- `CUBEJS_API_SECRET` is configured (the generated app secret supplies it by default; provide an external
|
||||
endpoint if you set `cube.enabled: false`)
|
||||
|
||||
## 5. Key Values
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"generate": "turbo run generate",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test --no-cache",
|
||||
"test:coverage": "turbo run test:coverage --no-cache",
|
||||
"test:e2e": "playwright test",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/ai.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user