Compare commits

...

46 Commits

Author SHA1 Message Date
Labeeb 914a165d6a fix(i18n): add dedicated inactive-survey heading translation keys (#7836)
Use separate heading keys for paused/completed/link-expired states in SurveyInactive and add the new keys across all web locale files so titles no longer duplicate description copy.
2026-04-25 09:39:23 -04:00
Labeeb 7fba2247fb fix: 7817 use fully translated inactive survey headings 2026-04-25 06:42:44 -04:00
Tiago 4128731c5f fix: password hash visibility improvement (#7814)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-24 18:16:59 +00:00
Matti Nannt ef96426ca0 chore(security): dependency audit — reduce attack surface & resolve all vulnerabilities (#7801)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:32:32 +02:00
Dhruwang Jariwala ce1dbe8b00 fix: apply plan changes immediately for non-standard plans (#7807) 2026-04-24 07:38:33 +00:00
Dhruwang Jariwala 444f043140 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:36:10 +00:00
Dhruwang Jariwala 2d32c0d671 feat: add iframe preview to website embed tab (#7791)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 06:36:55 +00:00
Dhruwang Jariwala 8dc70a5e30 fix: prevent survey widget CSS from polluting host page styles (#7805)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:44 +00:00
Aryan Ghugare 3e4e55fbf1 fix: prevent bypass of single-use survey restriction via v1 API (#7735)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 09:48:18 +00:00
Tiago fcfedd6e15 feat: replace minio with rustfs (#7742) 2026-04-22 08:07:38 +00:00
Tiago 6c4342690f fix: harden legacy SSO relinking (#7755) 2026-04-22 07:35:23 +00:00
arasucar b8c361fcf3 refactor: use context instead of prop drilling in survey analysis components (#6223) (#7754)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 06:00:13 +00:00
Matti Nannt 8771a0ec91 fix: lodash vulnerability (#7800) 2026-04-22 05:57:08 +00:00
Bhagya Amarasinghe fc33c52133 fix: patch protobufjs transitive vulnerabilities (#7790) 2026-04-21 09:59:12 +00:00
Balázs Úr 75cf9293b1 fix: Hungarian translation (#7752) 2026-04-21 08:41:53 +00:00
Serhat e489c6a346 feat: Add Turkish (tr) translations (#7645)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-20 12:51:25 +00:00
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
Dhruwang Jariwala 60bd5cbeff fix: prevent environment ID leak in API error responses (#7753)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:38:32 +00:00
Dhruwang Jariwala b6a3a15379 fix: make other option input field mandatory when sole selection (#7751)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:06:00 +00:00
Johannes c68f214eff fix: keep sidebar switcher icons round with long labels (#7756)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 08:04:10 +00:00
Harsh Bhat c90ee84483 chore: Add survey to formbricks docs (#7746) 2026-04-16 12:13:55 +00:00
Dhruwang Jariwala dc1ee72594 chore: translation management revamp (scope 1) (#7733)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-16 11:18:48 +00:00
Dhruwang Jariwala 924132287e fix: connect rating/NPS scale labels to label styling settings (#7738)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:59:59 +00:00
Dhruwang Jariwala e6f347aa07 fix: remove dark: variant classes from survey-ui to prevent host page style leakage (#7747) 2026-04-16 05:50:46 +00:00
Dhruwang Jariwala 367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Marius 0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
Dhruwang Jariwala 439dd0b44e fix: add loading skeleton for responses page (#7700)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-13 16:56:20 +00:00
Anshuman Pandey 2556f5e15d fix: add missing PostHog events (#7722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:57:12 +00:00
Johannes cc0eec3bf0 feat: add auto-progress mode for rating and NPS surveys (#7709)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-13 11:22:50 +00:00
Johannes 4b009a8eb4 revert: enhance welcome card to support video uploads (#7712)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 08:17:05 +00:00
Johannes 2aaddf7306 fix: prevent TTC overcount for multi-question blocks (#7713)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 07:56:40 +00:00
Dhruwang Jariwala fb5d6145d0 fix: only show beforeunload warning when offline support is active (#7715) 2026-04-13 07:19:57 +00:00
Dhruwang Jariwala 59310bac93 fix: validate "Other" option text on required questions and remove duplicate response entry (#7716) 2026-04-13 07:05:08 +00:00
392 changed files with 20127 additions and 7966 deletions
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@v3
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+37 -48
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
@@ -85,65 +85,48 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
- name: Start RustFS Server
run: |
set -euo pipefail
# Start MinIO server in background
# Start RustFS server in background
docker run -d \
--name minio-server \
--name rustfs-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
echo "MinIO server started"
echo "RustFS server started"
- name: Wait for MinIO and create S3 bucket
- name: Bootstrap RustFS bucket and browser upload CORS
run: |
set -euo pipefail
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
- name: Build App
run: |
@@ -242,8 +225,14 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+3 -2
View File
@@ -2,6 +2,7 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -39,7 +40,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -49,7 +50,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+9 -9
View File
@@ -12,18 +12,18 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/addon-links": "10.3.5",
"@storybook/addon-onboarding": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
}
}
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error instanceof PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -409,16 +409,22 @@ export const MainNavigation = ({
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
if (projectId === project.id) return;
const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
@@ -475,7 +481,7 @@ export const MainNavigation = ({
);
const switcherIconClasses =
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
return (
@@ -114,8 +114,12 @@ export const OrganizationBreadcrumb = ({
}
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentEnvironmentId) {
router.push(`/environments/${currentEnvironmentId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -152,9 +152,13 @@ export const ProjectBreadcrumb = ({
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
setIsProjectDropdownOpen(false);
router.push(targetPath);
});
};
@@ -3,25 +3,22 @@
import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
activeId,
}: SurveyAnalysisNavigationProps) => {
export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { environment } = useEnvironment();
const { survey } = useSurvey();
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const url = `/environments/${environment.id}/surveys/${survey.id}`;
const navigation = [
{
@@ -31,7 +28,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
{
@@ -41,7 +38,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
];
@@ -0,0 +1,22 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="" />
<div className="flex h-9 animate-pulse gap-2">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="summary" />
</PageContentWrapper>
);
};
export default Loading;
@@ -1,5 +1,4 @@
import { TFunction } from "i18next";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -9,6 +8,7 @@ import {
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
import { capitalize } from "@/lib/utils/object";
export const getAddressFieldLabel = (field: string, t: TFunction) => {
switch (field) {
@@ -0,0 +1,23 @@
"use client";
import { useTranslation } from "react-i18next";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.responses")} />
<div className="flex h-9 animate-pulse gap-1.5">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="responseTable" />
</PageContentWrapper>
);
};
export default Loading;
@@ -64,8 +64,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -76,7 +74,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
<SurveyAnalysisNavigation activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -4,16 +4,13 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Confetti } from "@/modules/ui/components/confetti";
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
export const SuccessMessage = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading}
/>
@@ -5,14 +5,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
@@ -23,8 +22,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -41,8 +38,6 @@ interface ModalState {
}
export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
user,
publicDomain,
@@ -64,7 +59,8 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { project } = useEnvironment();
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -183,7 +179,7 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown environment={environment} survey={survey} />
<SurveyStatusDropdown />
)}
<IconBar actions={iconActions} />
@@ -215,7 +211,7 @@ export const SurveyAnalysisCTA = ({
projectCustomScripts={project.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
<SuccessMessage />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -16,13 +16,19 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslation();
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
const separator = surveyUrl.includes("?") ? "&" : "?";
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${iframeSrc}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
return (
<>
<CodeBlock language="html" noMargin>
@@ -48,6 +54,15 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
{t("common.copy_code")}
<CopyIcon />
</Button>
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
<iframe
title={t("common.preview")}
src={previewSrc}
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</>
);
};
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -178,19 +178,74 @@ describe("getSurveySummaryMeta", () => {
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
expect(meta.dropOffPercentage).toBe(0);
expect(meta.ttcAverage).toBe(0);
});
test("uses block-level TTC to avoid multiplying by number of elements", () => {
const surveyWithOneBlockThreeElements: TSurvey = {
...mockBaseSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q3",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q3" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
] as TSurveyElement[],
},
],
questions: [],
};
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b", q3: "c" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
finished: true,
},
] as any;
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
expect(meta.ttcAverage).toBe(5000);
});
});
describe("getSurveySummaryDropOff", () => {
@@ -274,7 +329,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
finished: boolean;
}
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[],
displayCount: number,
quotas: TSurveySummary["quotas"]
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
if (response.ttc?._total) {
const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
ttcResponseCount++;
return acc + response.ttc._total;
return acc + responseTtcTotal;
}
return acc;
}, 0);
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => {
// Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
const blockId = elementIdToBlockId[elementId];
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
responseCounts[elementId]++;
}
});
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const [meta, elementSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
return {
meta,
@@ -1061,7 +1094,9 @@ export const getResponsesForSummary = reactCache(
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
@@ -1070,6 +1105,10 @@ export const getResponsesForSummary = reactCache(
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
};
})
);
@@ -66,8 +66,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -78,7 +76,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
<SurveyAnalysisNavigation activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
@@ -3,8 +3,9 @@
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -16,17 +17,9 @@ import {
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
survey: TSurvey;
}
export const SurveyStatusDropdown = ({
environment,
updateLocalSurveyStatus,
survey,
}: SurveyStatusDropdownProps) => {
export const SurveyStatusDropdown = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const router = useRouter();
@@ -46,10 +39,6 @@ export const SurveyStatusDropdown = ({
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
@@ -0,0 +1,8 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -0,0 +1,10 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
const tableHeaders = [
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("environments.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500">
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
))}
</div>
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
try {
airtableArray = await getAirtableTables(params.environmentId);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
}
if (isReadOnly) {
return redirect("./");
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { captureSurveyResponsePostHogEvent } from "./posthog";
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
describe("captureSurveyResponsePostHogEvent", () => {
afterEach(() => {
vi.clearAllMocks();
});
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
responseCount,
});
test("fires on 1st response with milestone 'first'", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
});
test("fires on every 100th response", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [100, 200, 300, 500, 1000, 5000]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("does NOT fire for 2nd through 99th responses", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 10, 50, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("does NOT fire for non-100th counts above 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [101, 150, 250, 499, 501]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("sets milestone to count string for non-first milestones", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(200));
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
expect.objectContaining({
is_first_response: false,
milestone: "200",
})
);
});
});
@@ -0,0 +1,33 @@
import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
surveyId: string;
surveyType: string;
environmentId: string;
responseCount: number;
}
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
+9 -20
View File
@@ -1,7 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -9,12 +8,10 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
@@ -27,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -302,25 +300,16 @@ export const POST = async (request: Request) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Sampled PostHog tracking: first response + every 100th
if (POSTHOG_KEY) {
const responseCount = await cache.withCache(
() => getResponseCountBySurveyId(surveyId),
createCacheKey.response.countBySurveyId(surveyId),
60 * 1000
);
const responseCount = await getResponseCountBySurveyId(surveyId);
if (responseCount === 1 || responseCount % 100 === 0) {
capturePostHogEvent(organization.id, "survey_response_received", {
survey_id: surveyId,
survey_type: survey.type,
organization_id: organization.id,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
}
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
surveyId,
surveyType: survey.type,
environmentId,
responseCount,
});
}
// Send telemetry events
@@ -185,4 +185,20 @@ describe("auth route audit logging", () => {
})
);
});
test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => {
const authOptions = await getWrappedAuthOptions("req-sso-recovery");
const user = {
id: "user_4",
email: "user4@example.com",
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
});
});
@@ -26,6 +26,12 @@ const getAuthMethod = (account: Account | null) => {
return "unknown";
};
const isSsoRecoveryVerificationFlow = (account: Account | null, user: User | AdapterUser) =>
account?.provider === "token" &&
"authFlowPurpose" in user &&
typeof user.authFlowPurpose === "string" &&
user.authFlowPurpose === "sso_recovery";
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -117,6 +123,10 @@ const handler = async (req: Request, ctx: any) => {
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
if (isSsoRecoveryVerificationFlow(account, user)) {
return;
}
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
@@ -0,0 +1,67 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { verifySsoRelinkIntent } from "@/lib/jwt";
import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
NEXT_AUTH_SESSION_COOKIE_NAMES,
getSessionTokenFromCookieHeader,
} from "@/modules/auth/lib/session-cookie";
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery";
const clearSessionCookies = (response: NextResponse) => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
response.cookies.set({
name: cookieName,
value: "",
expires: new Date(0),
path: "/",
secure: cookieName.startsWith("__Secure-"),
});
}
};
const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: string) => {
const response = NextResponse.redirect(getSsoRecoveryFailureRedirectUrl(callbackUrl));
clearSessionCookies(response);
const sessionToken = getSessionTokenFromCookieHeader(request.headers.get("cookie"));
if (!sessionToken) {
return response;
}
try {
await deleteSessionBySessionToken(sessionToken);
} catch (error) {
logger.error(error, "Failed to delete SSO recovery session after recovery completion error");
}
return response;
};
export const GET = async (request: Request) => {
const url = new URL(request.url);
const intentToken = url.searchParams.get("intent");
if (!intentToken) {
return NextResponse.redirect(getSsoRecoveryFailureRedirectUrl());
}
try {
const session = await getServerSession(authOptions);
const callbackUrl = await completeSsoRecovery({
intentToken,
sessionUserId: session?.user.id,
});
return NextResponse.redirect(callbackUrl);
} catch {
try {
const intent = verifySsoRelinkIntent(intentToken);
return await buildFailedRecoveryResponse(request, intent.callbackUrl);
} catch {
return await buildFailedRecoveryResponse(request);
}
}
};
@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
@@ -10,6 +11,8 @@ import {
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
@@ -0,0 +1,98 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getResponseIdByDisplayId } from "./response";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findFirst: vi.fn(),
},
},
}));
describe("getResponseIdByDisplayId", () => {
const environmentId = "env1234567890123456789012";
const displayId = "display1234567890123456789";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the linked responseId when a response exists", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: {
id: "response123456789012345678",
},
} as any);
const result = await getResponseIdByDisplayId(environmentId, displayId);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[displayId, expect.any(Object)]
);
expect(prisma.display.findFirst).toHaveBeenCalledWith({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
expect(result).toEqual({ responseId: "response123456789012345678" });
});
test("returns null when the display exists but has no response", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: null,
} as any);
await expect(getResponseIdByDisplayId(environmentId, displayId)).resolves.toEqual({
responseId: null,
});
});
test("throws ResourceNotFoundError when the display does not exist in the environment", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(
new ResourceNotFoundError("Display", displayId)
);
});
test("throws ValidationError when input validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(ValidationError);
expect(prisma.display.findFirst).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(DatabaseError);
});
});
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
environmentId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([environmentId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,70 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getResponseIdByDisplayId } from "./lib/response";
import { GET } from "./route";
vi.mock("@/app/lib/api/with-api-logging", async () => {
return {
withV1ApiWrapper:
({ handler }: { handler: any }) =>
async (req: NextRequest, props: any) => {
const result = await handler({ req, props });
return result.response;
},
};
});
vi.mock("./lib/response", () => ({
getResponseIdByDisplayId: vi.fn(),
}));
describe("GET /api/v1/client/[environmentId]/displays/[displayId]/response", () => {
const req = new NextRequest("http://localhost/api/v1/client/env/displays/display/response");
const props = {
params: Promise.resolve({
environmentId: "env1234567890123456789012",
displayId: "display1234567890123456789",
}),
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the responseId when a linked response exists", async () => {
vi.mocked(getResponseIdByDisplayId).mockResolvedValue({ responseId: "response123456789012345678" });
const response = await GET(req, props);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: {
responseId: "response123456789012345678",
},
});
});
test("returns null when the display exists without a response", async () => {
vi.mocked(getResponseIdByDisplayId).mockResolvedValue({ responseId: null });
const response = await GET(req, props);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: {
responseId: null,
},
});
});
test("returns 404 when the display is missing for the environment", async () => {
vi.mocked(getResponseIdByDisplayId).mockRejectedValue(
new ResourceNotFoundError("Display", "display1234567890123456789")
);
const response = await GET(req, props);
expect(response.status).toBe(404);
});
});
@@ -0,0 +1,40 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
const params = await props.params;
try {
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -70,6 +70,7 @@ const mockEnvironmentData = {
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
triggers: [],
displayPercentage: null,
delay: 0,
@@ -122,6 +123,13 @@ describe("getEnvironmentStateData", () => {
surveys: expect.any(Object),
}),
});
const prismaCall = vi.mocked(prisma.environment.findUnique).mock.calls[0][0];
expect(prismaCall.select.surveys.select).toEqual(
expect.objectContaining({
isAutoProgressingEnabled: true,
})
);
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
@@ -121,6 +121,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
displayOption: true,
hiddenFields: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
triggers: {
select: {
actionClass: {
@@ -10,6 +10,8 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -123,17 +125,118 @@ export const POST = withV1ApiWrapper({
}
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
),
response: responses.badRequestResponse("Survey does not belong to this environment", undefined, true),
};
}
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInputData.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInputData.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (survey.singleUse.isEncrypted) {
if (!ENCRYPTION_KEY) {
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
let decryptedSuId: string;
try {
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
} catch {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (decryptedSuId !== responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
} else if (responseInputData.singleUseId !== suId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
@@ -75,11 +75,7 @@ export const POST = withV1ApiWrapper({
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey does not belong to the environment",
{ surveyId, environmentId },
true
),
response: responses.badRequestResponse("Survey does not belong to this environment", undefined, true),
};
}
@@ -5,7 +5,9 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -76,16 +78,31 @@ export const GET = withV1ApiWrapper({
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
data: existingData,
email,
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
@@ -1,8 +1,7 @@
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { getAirtableToken, getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
};
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const integration = await getIntegrationByType(environmentId, "airtable");
if (!integration) {
return {
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
};
}
const tables = await getTables(integration.config.key, baseId.data);
// Use getAirtableToken to ensure the access token is refreshed if expired
const freshAccessToken = await getAirtableToken(environmentId);
const tables = await getTables(
{ ...integration.config.key, access_token: freshAccessToken },
baseId.data
);
return {
response: responses.successResponse(tables),
};
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -11,6 +12,8 @@ import {
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import {
TIntegrationSlackConfig,
TIntegrationSlackConfigData,
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
@@ -96,14 +96,7 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
}
if (survey.environmentId !== environmentId) {
return {
error: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
),
error: responses.badRequestResponse("Survey does not belong to this environment", undefined, true),
};
}
return { survey };
@@ -1,47 +1,19 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
environmentId: "clq5n7p1q0000m7z0h5p6g3r3",
type: "link",
segment: null,
triggers: [],
@@ -56,66 +28,20 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
});
test("should handle generic errors during deletion", async () => {
test("rethrows shared delete service errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
mockDeleteSharedSurvey.mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
});
});
@@ -1,43 +1,3 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
@@ -1,5 +1,6 @@
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
@@ -70,6 +71,12 @@ export const GET = withV1ApiWrapper({
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return {
response: handleErrorResponse(error),
};
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -175,10 +175,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -129,6 +129,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}
@@ -124,11 +124,8 @@ describe("checkSurveyValidity", () => {
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another environment",
{
"survey.environmentId": "env-2",
environmentId: "env-1",
},
"Survey does not belong to this environment",
undefined,
true
);
});
@@ -17,14 +17,7 @@ export const checkSurveyValidity = async (
responseInput: TResponseInputV2
): Promise<Response | null> => {
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
}
if (survey.type === "link" && survey.singleUse?.enabled) {
@@ -1,6 +1,6 @@
import { UAParser } from "ua-parser-js";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -177,6 +177,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
+132
View File
@@ -9,6 +9,22 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: 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: mockGetServerSession,
}));
@@ -25,6 +41,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
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(() => ({
@@ -45,6 +69,114 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks();
});
test("passes an audit log to the handler and queues success after the response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ auditLog }) => {
expect(auditLog).toEqual(
expect.objectContaining({
action: "deleted",
targetType: "survey",
userId: "user_1",
userType: "user",
status: "failure",
})
);
if (auditLog) {
auditLog.targetId = "survey_1";
auditLog.organizationId = "org_1";
auditLog.oldObject = { id: "survey_1" };
}
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
method: "DELETE",
headers: { "x-request-id": "req-audit" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: { id: "survey_1" },
})
);
});
test("queues a failure audit log when the handler returns a non-ok response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.targetId = "survey_2";
}
return new Response("forbidden", { status: 403 });
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
method: "DELETE",
headers: {
"x-request-id": "req-failure-audit",
"x-api-key": "fbk_test",
},
}),
{} as never
);
expect(response.status).toBe(403);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_2",
organizationId: "org_1",
userId: "key_1",
userType: "api",
status: "failure",
eventId: "req-failure-audit",
})
);
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+76 -2
View File
@@ -4,10 +4,13 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -15,7 +18,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -48,6 +52,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -293,10 +299,61 @@ async function applyV3RateLimitOrRespond(params: {
return null;
}
function buildV3AuditLog(
authentication: TV3Authentication,
action?: TAuditAction,
targetType?: TAuditTarget,
apiUrl?: string
): TV3AuditLog | undefined {
if (!authentication || !action || !targetType || !apiUrl) {
return undefined;
}
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
} else if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
return auditLog;
}
async function queueV3AuditLog(
auditLog: TV3AuditLog | undefined,
requestId: string,
log: ReturnType<typeof logger.withContext>
): Promise<void> {
if (!auditLog) {
return;
}
try {
await queueAuditEvent({
...auditLog,
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
});
} catch (error) {
log.error({ error }, "Failed to queue V3 audit event");
}
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -306,6 +363,7 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
method: req.method,
path: instance,
});
let auditLog: TV3AuditLog | undefined;
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -331,17 +389,33 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse;
}
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({
req,
props,
authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId);
} catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
+25
View File
@@ -7,6 +7,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -93,3 +94,27 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
describe("successResponse", () => {
test("wraps the payload in a data envelope", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("allows custom status and cache headers", async () => {
const res = successResponse(
{ ok: true },
{
cache: "private, max-age=60",
status: 202,
}
);
expect(res.status).toBe(202);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
+24
View File
@@ -147,3 +147,27 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
}
return Response.json({ data, meta }, { status: 200, headers });
}
export function successResponse<T>(
data: T,
options?: { requestId?: string; cache?: string; status?: number }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: options?.status ?? 200,
headers,
}
);
}
+2
View File
@@ -1,4 +1,6 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -0,0 +1,321 @@
import { ApiKeyPermission, EnvironmentType } 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 environmentId = "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 },
},
environmentPermissions: [
{
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
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",
environmentId,
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,
environmentId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
environmentId,
projectId: "proj_1",
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) }),
environmentId,
"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" }),
environmentId,
"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,
environmentId,
}),
})
);
});
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,
environmentId,
}),
})
);
});
});
@@ -0,0 +1,72 @@
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 { deleteSurvey } from "@/modules/survey/lib/surveys";
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
},
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(
authentication,
survey.environmentId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.organizationId = authResult.organizationId;
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
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 delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+1 -1
View File
@@ -321,11 +321,11 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
expect(body.data[0].singleUse).toBeNull();
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
export type TV3SurveyListItem = Omit<TSurvey, "environmentId"> & {
workspaceId: string;
};
@@ -9,7 +9,7 @@ export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
const { environmentId, ...rest } = survey;
return {
...rest,
+50
View File
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
+27 -1
View File
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests";
| "too_many_requests"
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};
+8 -7
View File
@@ -971,13 +971,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.improve_trial_conversion_question_2_headline"),
headline: t("templates.improve_trial_conversion_question_3_headline"),
required: true,
inputType: "text",
}),
],
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")],
buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"),
buttonLabel: t("templates.improve_trial_conversion_question_3_button_label"),
t,
}),
buildBlock({
@@ -1647,14 +1647,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [
buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: "What's your primary goal for using $[projectName]?",
headline: t("templates.identify_customer_goals_question_1_headline"),
required: true,
shuffleOption: "none",
choices: [
"Understand my user base deeply",
"Identify upselling opportunities",
"Build the best possible product",
"Rule the world to make everyone breakfast brussels sprouts.",
t("templates.identify_customer_goals_question_1_choice_1"),
t("templates.identify_customer_goals_question_1_choice_2"),
t("templates.identify_customer_goals_question_1_choice_3"),
t("templates.identify_customer_goals_question_1_choice_4"),
],
}),
],
@@ -4926,6 +4926,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
showLanguageSwitch: false,
followUps: [],
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: false,
metadata: {},
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
+1
View File
@@ -19,6 +19,7 @@
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW"
]
+45 -26
View File
@@ -51,6 +51,8 @@ checksums:
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
@@ -123,6 +125,7 @@ checksums:
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
common/choice_n: ee41eb382bae7289a221d959f3046965
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -136,6 +139,7 @@ checksums:
common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/column_n: 550955aee6a92d8ccc96989300add693
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configuration: 923ec0502721489202f6222dd4107163
common/confirm: 90930b51154032f119fa75c1bd422d8b
@@ -207,6 +211,7 @@ checksums:
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/field_placeholder: ec26d96643d86da164162204ec6c650f
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
@@ -218,10 +223,12 @@ checksums:
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
common/go_back: b917ea82facb90c88c523b255d29f84b
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
common/headline: 0023cbe059bbadcc77312825cbbce5ac
common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/html: f750870203043349d570d8f5865ca0f8
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
@@ -269,7 +276,6 @@ checksums:
common/months: da74749fbe80394fa0f72973d7b0964a
common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
common/my_product: ad022177062f9ef6e9acf33b13e889aa
common/name: 9368b5a047572b6051f334af5aa76819
common/new: 126d036fae5fb6b629728ecb97e6195b
@@ -284,6 +290,7 @@ checksums:
common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
common/no_surveys_found: 7b74706fe4f4aacd7d858e19e444fe85
common/no_text_found: 27350f35bdd57b3701c7ec578a1a0e11
common/none_of_the_above: e007f0b1e046d5ddbbcfbd87940456ee
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
@@ -307,7 +314,7 @@ checksums:
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
@@ -325,7 +332,6 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
@@ -352,6 +358,7 @@ checksums:
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f
common/row_n: eb5bb04b244fadd7a6962aa58bf6bd17
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
@@ -390,6 +397,7 @@ checksums:
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
common/string: 4ddccc1974775ed7357f9beaf9361cec
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
common/subheader: 73a37d57cb9807e574a42bd0c7e334ed
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
common/summary: 13eb7b8a239fb4702dfdaee69100a220
common/survey: b659d270a53dada994d926e0cc6e9a54
@@ -411,6 +419,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -457,7 +466,6 @@ checksums:
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
@@ -781,6 +789,9 @@ checksums:
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
@@ -1228,15 +1239,9 @@ checksums:
environments/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
environments/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
environments/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
environments/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
environments/surveys/copy_survey_description: 66d0aadf192ad5790fbf3f55f3bb5485
environments/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
environments/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
environments/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
environments/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
environments/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
environments/surveys/edit/1_choose_the_default_language_for_this_survey: d22759857c1bb3d6b337e8e9d501dad7
environments/surveys/edit/2_activate_translation_for_specific_languages: 9f23cb81ad301073df45ae36f0d94f9e
environments/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
@@ -1285,6 +1290,8 @@ checksums:
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
environments/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -1330,6 +1337,7 @@ checksums:
environments/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
environments/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
environments/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
environments/surveys/edit/change_default: 6236a6c8a28489ba7c4cad7426806859
environments/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
environments/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93
environments/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836
@@ -1342,6 +1350,7 @@ checksums:
environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
environments/surveys/edit/code: 343bc5386149b97cece2b093c39034b2
environments/surveys/edit/color: 9d53d1d120e8b8954bcae9a322573748
environments/surveys/edit/column_used_in_logic_error: deffbd3e8f4bd71a5e522682e8ee60dd
environments/surveys/edit/columns: 14896556dc1535d70198854757f704ec
@@ -1366,6 +1375,7 @@ checksums:
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
@@ -1385,7 +1395,6 @@ checksums:
environments/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
@@ -1521,11 +1530,13 @@ checksums:
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
environments/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
environments/surveys/edit/manage_languages: fe82303bc27b55ccfc076b527b185e39
environments/surveys/edit/manage_translations: 09b01c5c251e6dbc3dc6cd8b33fb6301
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
environments/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
@@ -1533,7 +1544,7 @@ checksums:
environments/surveys/edit/next_button_label: 39f1e82ae1dea5e400e8ed7c98c6ad9c
environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
environments/surveys/edit/no_images_found_for: 7dabcbcc7084f59c6ec0971895dfcd29
environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 4e66397232da6a463708220dc020bf42
environments/surveys/edit/no_option_found: a1a3aa7e6c13b6bb8df20a1a104c7c04
environments/surveys/edit/no_recall_items_found: 729e2b02e412cdc79f5ad94b1918620c
environments/surveys/edit/no_variables_yet_add_first_one_below: c8704b9ebc9c26c0e9dd50c099ba88cd
@@ -1560,6 +1571,7 @@ checksums:
environments/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
environments/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
environments/surveys/edit/present_your_survey_in_multiple_languages: 37f28b0a092d68322fedbc2e0c221ef3
environments/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
environments/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
environments/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
@@ -1649,6 +1661,7 @@ checksums:
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
environments/surveys/edit/show_in_order: 15784a59572eb8a6dba6b918c31a9493
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
@@ -1680,7 +1693,6 @@ checksums:
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
@@ -1688,9 +1700,11 @@ checksums:
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: e45beba7ae126775f4966776c982a3b4
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
environments/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
environments/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
environments/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
environments/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
environments/surveys/edit/type_field_id: 714b845806236bb8a9d6a09933b836e9
@@ -1763,6 +1777,7 @@ checksums:
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
environments/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
@@ -1945,7 +1960,6 @@ checksums:
environments/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
environments/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
environments/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
environments/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
environments/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
environments/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -2021,6 +2035,7 @@ checksums:
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
@@ -2029,7 +2044,6 @@ checksums:
environments/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
environments/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
environments/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
environments/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
environments/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
environments/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
environments/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
@@ -2106,7 +2120,6 @@ checksums:
environments/workspace/languages/duplicate_language_or_language_id: 0e17e3794b24e2428ca6ffadae0d08f3
environments/workspace/languages/edit_languages: c9d36f6b28557cc7d54e87c37dc18fdd
environments/workspace/languages/identifier: 7d8ade6b85e96216bcd73adeeeeecd8c
environments/workspace/languages/incomplete_translations: d82908b5725f18f5849c7876ad497ebc
environments/workspace/languages/language: 277fd1a41cc237a437cd1d5e4a80463b
environments/workspace/languages/language_deleted_successfully: 4a805d030491f3fe608d2371b0cfcd83
environments/workspace/languages/languages_updated_successfully: 60de474c99c5059c0458cddd0b016c15
@@ -2117,7 +2130,6 @@ checksums:
environments/workspace/languages/remove_language: 1a64563b0f37109f97b78eddd493e381
environments/workspace/languages/remove_language_from_surveys_to_remove_it_from_workspace: 61bc96f9db31a29a649cc9ecd684bc39
environments/workspace/languages/search_items: b54b751c8b075200be579d6c8e58096b
environments/workspace/languages/translate: 59f9803b27e2030ba7323ed239116cf7
environments/workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9
environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
environments/workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
@@ -2175,12 +2187,12 @@ checksums:
environments/workspace/look/advanced_styling_field_track_bg_description: 8a56258273dfe49e83fe752ea9e8daed
environments/workspace/look/advanced_styling_field_track_height: 9ce57cb4583039c224a37e013efb6b8f
environments/workspace/look/advanced_styling_field_track_height_description: 90243a4374e15d9118ad0fd93d5f3614
environments/workspace/look/advanced_styling_field_upper_label_color: 65d75c60dfdba88e5fed38bcb24a0a5d
environments/workspace/look/advanced_styling_field_upper_label_color_description: ae2276506807c7ceeb7a8b87723a8dd4
environments/workspace/look/advanced_styling_field_upper_label_size: ea0ca9a3ffa1650f97a31df453b0afc7
environments/workspace/look/advanced_styling_field_upper_label_size_description: 061668625be0f7a68ebc2e2ebe49e5a9
environments/workspace/look/advanced_styling_field_upper_label_weight: 946c5836d2cfaaee21e494424d550887
environments/workspace/look/advanced_styling_field_upper_label_weight_description: 916b03c719a8dead351679336aabcf53
environments/workspace/look/advanced_styling_field_upper_label_color: 2767a5db32742073a01aac16488e93dc
environments/workspace/look/advanced_styling_field_upper_label_color_description: 58f43ce21b7f6539cc937aa80c7e8060
environments/workspace/look/advanced_styling_field_upper_label_size: 3342babd1df61a3bdf7a3284137f7c24
environments/workspace/look/advanced_styling_field_upper_label_size_description: 867a89a79ed7ac7f1c6b0f3481a67f26
environments/workspace/look/advanced_styling_field_upper_label_weight: a9a0de9e840518d282cfdbcb02d059b5
environments/workspace/look/advanced_styling_field_upper_label_weight_description: 3cee88e1c8e75548dcb6004f0e44f31c
environments/workspace/look/advanced_styling_section_buttons: 3b44d6e2800e7bf3f133f1bce435f4c2
environments/workspace/look/advanced_styling_section_headlines: 6def704c0ac2ecb5951400c806856a41
environments/workspace/look/advanced_styling_section_inputs: 76bbeb561122a72fd3ec8c49eff7c563
@@ -2713,6 +2725,11 @@ checksums:
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
templates/identify_customer_goals_question_1_headline: bd9cd414fb723110d7f0a786bbf89d6c
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
@@ -2787,12 +2804,14 @@ checksums:
templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_2_headline: 05dd4820f60b9d267a9affc7e662f029
templates/improve_trial_conversion_question_3_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_3_headline: 3daeccf3dfc7bf8e9868c10fb3ea0b19
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca
templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_5_headline: dbd99e216fcbf8693b8e77fbd77e1c84
templates/improve_trial_conversion_question_5_subheader: b9b478e967930358b0c74324a7c18fc8
templates/improve_trial_conversion_question_5_subheader: 859876a442a633f4aa0d78fd0ee4ab4c
templates/improve_trial_conversion_question_6_headline: f15239ecc4f1a6bd8bea77a38b39c844
templates/improve_trial_conversion_question_6_subheader: e147ddbb609fff6e6fc78fb1f4add0ac
templates/integration_setup_survey_description: 696ccab07d7098cdb79c224fa1208889
+11 -5
View File
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
ZIntegrationAirtableBases,
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return ZIntegrationAirtableBases.parse(res);
};
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return res;
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key
+1
View File
@@ -182,6 +182,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW",
];
+7
View File
@@ -213,6 +213,13 @@ export const appLanguages = [
native: "Svenska",
},
},
{
code: "tr-TR",
label: {
"en-US": "Turkish",
native: "Türkçe",
},
},
{
code: "zh-Hans-CN",
label: {
+11 -3
View File
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import {
TIntegration,
TIntegrationByType,
TIntegrationInput,
ZIntegrationType,
} from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
});
export const getIntegrationByType = reactCache(
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
async <T extends TIntegrationInput["type"]>(
environmentId: string,
type: T
): Promise<TIntegrationByType<T> | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
try {
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
},
},
});
return integration ? transformIntegration(integration) : null;
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+78
View File
@@ -6,11 +6,13 @@ import {
createEmailChangeToken,
createEmailToken,
createInviteToken,
createSsoRelinkIntent,
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
verifyToken,
verifyTokenForLinkSurvey,
} from "./jwt";
@@ -380,6 +382,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -414,6 +417,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -425,6 +429,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -1004,5 +1009,78 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
describe("SSO recovery support", () => {
test("creates verification tokens that preserve the recovery purpose", async () => {
const token = createToken(mockUser.id, { purpose: "sso_recovery", expiresIn: "15m" });
await expect(verifyToken(token)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
})
);
});
test("defaults legacy verification tokens to email_verification when purpose is missing", async () => {
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(legacyToken)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
})
);
});
test("round-trips SSO relink intents without losing callback state", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
expect(verifySsoRelinkIntent(intent)).toEqual({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
});
test("rejects expired SSO relink intents", () => {
const expiredIntent = jwt.sign(
{
userId: crypto.symmetricEncrypt(mockUser.id, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(mockUser.email, TEST_ENCRYPTION_KEY),
provider: "google",
providerAccountId: crypto.symmetricEncrypt("provider-123", TEST_ENCRYPTION_KEY),
callbackUrl: crypto.symmetricEncrypt("http://localhost:3000", TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifySsoRelinkIntent(expiredIntent)).toThrow();
});
test("rejects tampered SSO relink intents", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000",
});
const tamperedIntent = `${intent.slice(0, -1)}x`;
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
});
});
});
});
+108 -5
View File
@@ -1,4 +1,4 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
@@ -13,7 +13,39 @@ const decryptWithFallback = (encryptedText: string, key: string): string => {
}
};
export const createToken = (userId: string, options = {}): string => {
export const VERIFICATION_TOKEN_PURPOSES = ["email_verification", "sso_recovery"] as const;
export type TVerificationTokenPurpose = (typeof VERIFICATION_TOKEN_PURPOSES)[number];
export type TVerifyTokenPayload = JwtPayload & {
id: string;
email: string;
purpose: TVerificationTokenPurpose;
};
type TVerificationTokenOptions = SignOptions & {
purpose?: TVerificationTokenPurpose;
};
type TSsoRelinkIntentPayload = {
callbackUrl: string;
email: string;
provider: string;
providerAccountId: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
if (purpose && VERIFICATION_TOKEN_PURPOSES.includes(purpose as TVerificationTokenPurpose)) {
return purpose as TVerificationTokenPurpose;
}
return DEFAULT_VERIFICATION_TOKEN_PURPOSE;
};
export const createToken = (userId: string, options: TVerificationTokenOptions = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -23,7 +55,9 @@ export const createToken = (userId: string, options = {}): string => {
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
const { purpose = DEFAULT_VERIFICATION_TOKEN_PURPOSE, ...jwtOptions } = options;
return jwt.sign({ id: encryptedUserId, purpose }, NEXTAUTH_SECRET, jwtOptions);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
@@ -224,7 +258,72 @@ const getUserEmailForLegacyVerification = async (
return { userId: decryptedId, userEmail: foundUser.email };
};
export const verifyToken = async (token: string): Promise<JwtPayload> => {
const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
expiresIn: "15m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: symmetricEncrypt(payload.callbackUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
userId: string;
email: string;
provider: string;
providerAccountId: string;
callbackUrl: string;
};
if (
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
!payload?.callbackUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: decryptWithFallback(payload.callbackUrl, ENCRYPTION_KEY),
};
};
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -263,7 +362,11 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return { id: userData.userId, email: userData.userEmail };
return {
id: userData.userId,
email: userData.userEmail,
purpose: getVerificationTokenPurpose(payload.purpose),
};
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
+2 -6
View File
@@ -1,8 +1,4 @@
import {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIntegrationByType } from "../integration/service";
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
let results: TIntegrationNotionDatabase[] = [];
try {
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
const notionIntegration = await getIntegrationByType(environmentId, "notion");
if (notionIntegration && notionIntegration.config?.key.bot_id) {
results = await fetchPages(notionIntegration.config);
}
+1 -1
View File
@@ -63,7 +63,7 @@ const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): T
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
...(billing.stripe == null ? {} : { stripe: billing.stripe }),
};
};
+1 -4
View File
@@ -1,6 +1,3 @@
import structuredClonePolyfill from "@ungap/structured-clone";
const structuredCloneExport =
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
const structuredCloneExport = globalThis.structuredClone;
export { structuredCloneExport as structuredClone };
+5 -1
View File
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
// Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
return responseValue
.filter((v) => v !== "")
.map(findChoiceByLabel)
.filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { truncateText } from "../utils/strings";
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
try {
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
const slackIntegration = await getIntegrationByType(environmentId, "slack");
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}
+12 -7
View File
@@ -190,6 +190,14 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
showResponseCount: false,
};
const mockBlocks = [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
];
const baseSurveyProperties = {
id: mockId,
name: "Mock Survey",
@@ -201,14 +209,9 @@ const baseSurveyProperties = {
displayLimit: 3,
welcomeCard: mockWelcomeCard,
questions: [],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
],
blocks: mockBlocks as unknown as SurveyMock["blocks"],
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
endings: [
{
@@ -303,6 +306,7 @@ export const createSurveyInput: TSurveyCreateInput = {
displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }],
...baseSurveyProperties,
blocks: mockBlocks,
};
export const updateSurveyInput: TSurvey = {
@@ -325,6 +329,7 @@ export const updateSurveyInput: TSurvey = {
followUps: [],
...baseSurveyProperties,
...commonMockProperties,
blocks: mockBlocks,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
+7 -34
View File
@@ -5,7 +5,8 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
getOrganizationByEnvironmentId,
@@ -48,6 +49,7 @@ export const selectSurvey = {
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: true,
redirectUrl: true,
projectOverwrites: true,
@@ -557,22 +559,7 @@ export const updateSurveyInternal = async (
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
return transformPrismaSurvey<TSurvey>(prismaSurvey);
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -647,8 +634,8 @@ export const createSurvey = async (
}
// Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
if (Array.isArray(data.blocks) && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
}
const survey = await prisma.survey.create({
@@ -772,21 +759,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
});
}
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey = {
...prismaSurvey,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey as TSurvey;
return transformPrismaSurvey<TSurvey>(prismaSurvey);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+2 -1
View File
@@ -1,5 +1,5 @@
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
@@ -17,6 +17,7 @@ const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"tr-TR": tr,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
+20
View File
@@ -0,0 +1,20 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { publicUserSelect } from "./public-user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
vi.mock("@formbricks/database", () => ({
@@ -47,11 +48,6 @@ describe("User Service", () => {
locale: "en-US" as TUserLocale,
lastLoginAt: new Date(),
isActive: true,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
};
const mockOrganizations: TOrganization[] = [
@@ -102,8 +98,12 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("twoFactorSecret");
expect(result).not.toHaveProperty("backupCodes");
expect(result).not.toHaveProperty("identityProviderAccountId");
});
test("should return null when user not found", async () => {
@@ -134,7 +134,7 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { email: "test@example.com" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: expect.any(Object),
select: publicUserSelect,
});
});
+7 -21
View File
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { validateInputs } from "../utils/validate";
const responseSelection = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
};
import { publicUserSelect } from "./public-user";
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: responseSelection,
select: publicUserSelect,
});
return updatedUser;
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
} catch (error) {
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: responseSelection,
select: publicUserSelect,
});
return users;
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
+27
View File
@@ -0,0 +1,27 @@
export type DebouncedFunction<T extends (...args: any[]) => void> = ((...args: Parameters<T>) => void) & {
cancel: () => void;
};
export const debounce = <T extends (...args: any[]) => void>(
callback: T,
delay: number
): DebouncedFunction<T> => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const debounced = ((...args: Parameters<T>) => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => callback(...args), delay);
}) as DebouncedFunction<T>;
debounced.cancel = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
return debounced;
};
+35
View File
@@ -0,0 +1,35 @@
export const capitalize = (value: string): string => {
if (!value) return "";
return `${value.charAt(0).toUpperCase()}${value.slice(1).toLowerCase()}`;
};
export const isDeepEqual = (left: unknown, right: unknown): boolean => {
if (Object.is(left, right)) return true;
if (left instanceof Date && right instanceof Date) {
return left.getTime() === right.getTime();
}
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
return false;
}
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((item, index) => isDeepEqual(item, right[index]));
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(rightRecord, key) && isDeepEqual(leftRecord[key], rightRecord[key])
);
};
+1 -1
View File
@@ -54,7 +54,7 @@ export const findRecallInfoById = (text: string, id: string): string | null => {
return match ? match[0] : null;
};
const getRecallItemLabel = <T extends TSurvey>(
export const getRecallItemLabel = <T extends TSurvey>(
recallItemId: string,
survey: T,
languageCode: string
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,4 +32,23 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false);
});
});
describe("toSafeIdentifier", () => {
test("normalizes free-form labels into safe identifiers", () => {
expect(toSafeIdentifier("Date of Birth")).toBe("date_of_birth");
expect(toSafeIdentifier("Customer-ID")).toBe("customer_id");
expect(toSafeIdentifier(" Preferred Language ")).toBe("preferred_language");
expect(toSafeIdentifier("city__name")).toBe("city_name");
});
test("strips invalid leading characters until first lowercase letter", () => {
expect(toSafeIdentifier("123 Date")).toBe("date");
expect(toSafeIdentifier("__name")).toBe("name");
expect(toSafeIdentifier("99")).toBe("");
});
test("keeps already safe identifiers unchanged", () => {
expect(toSafeIdentifier("country_code")).toBe("country_code");
});
});
});
+38
View File
@@ -12,6 +12,44 @@ export const isSafeIdentifier = (value: string): boolean => {
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a free-form string to a safe identifier candidate.
* The output only contains lowercase letters, numbers, and underscores.
* It also ensures the identifier starts with a lowercase letter by stripping invalid leading chars.
*/
export const toSafeIdentifier = (value: string): string => {
const normalized = value.trim().toLowerCase();
let safeIdentifier = "";
let shouldInsertUnderscore = false;
for (const char of normalized) {
const isLowercaseLetter = char >= "a" && char <= "z";
const isDigit = char >= "0" && char <= "9";
if (isLowercaseLetter || isDigit) {
if (shouldInsertUnderscore && safeIdentifier.length > 0) {
safeIdentifier += "_";
}
safeIdentifier += char;
shouldInsertUnderscore = false;
continue;
}
if (safeIdentifier.length > 0) {
shouldInsertUnderscore = true;
}
}
for (let i = 0; i < safeIdentifier.length; i++) {
const char = safeIdentifier[i];
if (char >= "a" && char <= "z") {
return safeIdentifier.slice(i);
}
}
return "";
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
+47 -28
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig.",
"link_expired_heading": "Dein Link ist abgelaufen."
},
"common": {
"accepted": "Akzeptiert",
@@ -152,6 +153,7 @@
"centered_modal": "Zentriertes Modalfenster",
"change_organization": "Organisation wechseln",
"change_workspace": "Workspace wechseln",
"choice_n": "Auswahl {{n}}",
"choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen",
@@ -165,6 +167,7 @@
"close": "Schließen",
"code": "Code",
"collapse_rows": "Zeilen einklappen",
"column_n": "Spalte {{n}}",
"completed": "Abgeschlossen",
"configuration": "Konfiguration",
"confirm": "Bestätigen",
@@ -236,6 +239,7 @@
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"field_placeholder": "{{field}}-Platzhalter",
"filter": "Filter",
"finish": "Fertigstellen",
"first_name": "Vorname",
@@ -247,10 +251,12 @@
"generate": "Generieren",
"go_back": "Geh zurück",
"go_to_dashboard": "Zum Dashboard gehen",
"headline": "Überschrift",
"hidden": "Versteckt",
"hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder",
"hide_column": "Spalte ausblenden",
"html": "HTML",
"id": "ID",
"image": "Bild",
"images": "Bilder",
@@ -298,7 +304,6 @@
"months": "Monate",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
"my_product": "mein Produkt",
"name": "Name",
"new": "Neu",
@@ -313,6 +318,7 @@
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"no_text_found": "Kein Text gefunden",
"none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht berechtigt",
@@ -336,7 +342,7 @@
"organization_settings": "Organisationseinstellungen",
"other": "Andere",
"other_filters": "Weitere Filter",
"others": "Andere",
"other_placeholder": "Sonstiger Platzhalter",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
@@ -354,7 +360,6 @@
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"production": "Produktion",
@@ -381,6 +386,7 @@
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
"row_n": "Zeile {{n}}",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
@@ -419,6 +425,7 @@
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"string": "Text",
"styling": "Styling",
"subheader": "Unterüberschrift",
"submit": "Abschicken",
"summary": "Zusammenfassung",
"survey": "Umfrage",
@@ -487,7 +494,6 @@
"workspace_name_placeholder": "z. B. Formbricks",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
@@ -828,6 +834,9 @@
},
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
"slack": {
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
@@ -1301,16 +1310,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen",
"alphabetical": "alphabetisch",
"copy_survey": "Umfrage kopieren",
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
"activate_translations": "Übersetzungen aktivieren",
"add": "+ hinzufügen",
"add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.",
"add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu",
@@ -1359,6 +1362,8 @@
"assign": "Zuweisen =",
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
"auto_progress_rating_and_nps_description": "Automatisches Weitergehen bei Einzelfragen-Blöcken. Pflichtfragen blenden Weiter aus, außer wenn \"Sonstiges\" ausgewählt ist.",
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
@@ -1404,6 +1409,7 @@
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_default": "Standard ändern",
"change_question_type": "Fragetyp ändern",
"change_survey_type": "Die Änderung des Umfragetypen kann vorhandenen Zugriff beeinträchtigen",
"change_the_background_to_a_color_image_or_animation": "Hintergrund zu einer Farbe, einem Bild oder einer Animation ändern.",
@@ -1416,6 +1422,7 @@
"choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.",
"city": "Stadt",
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
"code": "Code",
"color": "Farbe",
"column_used_in_logic_error": "Diese Spalte wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"columns": "Spalten",
@@ -1440,6 +1447,7 @@
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"default_language": "Standardsprache",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
"delete_choice": "Auswahl löschen",
@@ -1459,7 +1467,6 @@
"duplicate_question": "Frage duplizieren",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"element_not_found": "Frage nicht gefunden",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Befragten erlauben, die Sprache jederzeit zu wechseln. Benötigt mind. 2 aktive Sprachen.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
@@ -1596,10 +1603,12 @@
"long_answer_toggle_description": "Ermöglichen Sie den Befragten, längere Antworten über mehrere Zeilen zu schreiben.",
"lower_label": "Unteres Label",
"manage_languages": "Sprachen verwalten",
"manage_translations": "Übersetzungen verwalten",
"matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen",
"max_file_size": "Maximale Dateigröße",
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
"missing_first": "Fehlende zuerst",
"move_question_to_block": "Frage in Block verschieben",
"multiply": "Multiplizieren *",
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
@@ -1607,7 +1616,7 @@
"next_button_label": "Beschriftung der Schaltfläche \"Weiter\"",
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder. Füge das erste unten hinzu.",
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
"no_languages_found_add_first_one_to_get_started": "In diesem Workspace wurden keine Umfragesprachen gefunden. Füge bitte eine hinzu, um loszulegen.",
"no_option_found": "Keine Option gefunden",
"no_recall_items_found": "Keine Recall-Elemente gefunden",
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
@@ -1634,6 +1643,7 @@
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
"please_specify": "Bitte angeben",
"present_your_survey_in_multiple_languages": "Präsentiere deine Umfrage in mehreren Sprachen",
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
"progress_saved": "Fortschritt gespeichert",
@@ -1725,6 +1735,7 @@
"seven_points": "7 Punkte",
"show_block_settings": "Block-Einstellungen anzeigen",
"show_button": "Button anzeigen",
"show_in_order": "In Reihenfolge anzeigen",
"show_language_switch": "Sprachwechsel anzeigen",
"show_multiple_times": "Begrenzte Anzahl von Malen anzeigen",
"show_only_once": "Nur einmal anzeigen",
@@ -1756,7 +1767,6 @@
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
"target_block_not_found": "Zielblock nicht gefunden",
"targeted": "Gezielt",
"ten_points": "10 Punkte",
@@ -1764,9 +1774,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
"then": "dann",
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
"this_will_remove_the_language_and_all_its_translations": "Dies entfernt diese Sprache und alle zugehörigen Übersetzungen aus dieser Umfrage. Diese Aktion kann nicht rückgängig gemacht werden.",
"three_points": "3 Punkte",
"times": "Zeiten",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
"translated": "Übersetzt",
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"type_field_id": "Feld-ID eingeben",
@@ -1841,6 +1853,7 @@
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
"visible": "Sichtbar",
"wait": "Warte",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
"waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
@@ -2049,7 +2062,6 @@
"downloading_qr_code": "QR-Code wird heruntergeladen",
"drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
@@ -2127,6 +2139,7 @@
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung",
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
"unknown_question_type": "Unbekannter Fragetyp",
"use_personal_links": "Nutze persönliche Links",
@@ -2136,7 +2149,6 @@
},
"survey_deleted_successfully": "Umfrage erfolgreich gelöscht",
"survey_duplicated_successfully": "Umfrage erfolgreich dupliziert",
"survey_duplication_error": "Duplizieren der Umfrage fehlgeschlagen",
"templates": {
"all_channels": "Alle Kanäle",
"all_industries": "Alle Branchen",
@@ -2224,7 +2236,6 @@
"duplicate_language_or_language_id": "Doppelte Sprache oder Sprach-ID",
"edit_languages": "Sprachen bearbeiten",
"identifier": "Kennung (ISO)",
"incomplete_translations": "Unvollständige Übersetzungen",
"language": "Sprache",
"language_deleted_successfully": "Sprache erfolgreich gelöscht",
"languages_updated_successfully": "Sprachen erfolgreich aktualisiert",
@@ -2234,8 +2245,7 @@
"please_select_a_language": "Bitte wähle eine Sprache aus",
"remove_language": "Sprache entfernen",
"remove_language_from_surveys_to_remove_it_from_workspace": "Bitte entferne die Sprache aus diesen Umfragen, um sie aus dem Workspace zu entfernen.",
"search_items": "Elemente suchen",
"translate": "Übersetzen"
"search_items": "Elemente suchen"
},
"look": {
"add_background_color": "Hintergrundfarbe hinzufügen",
@@ -2295,12 +2305,12 @@
"advanced_styling_field_track_bg_description": "Färbt den nicht ausgefüllten Teil des Balkens.",
"advanced_styling_field_track_height": "Track-Höhe",
"advanced_styling_field_track_height_description": "Steuert die Dicke des Fortschrittsbalkens.",
"advanced_styling_field_upper_label_color": "Farbe des oberen Labels",
"advanced_styling_field_upper_label_color_description": "Färbt die kleine Beschriftung über Eingabefeldern.",
"advanced_styling_field_upper_label_size": "Schriftgröße des oberen Labels",
"advanced_styling_field_upper_label_size_description": "Skaliert die kleine Beschriftung über Eingabefeldern.",
"advanced_styling_field_upper_label_weight": "Schriftstärke des oberen Labels",
"advanced_styling_field_upper_label_weight_description": "Macht die Beschriftung leichter oder fetter.",
"advanced_styling_field_upper_label_color": "Labelfarbe",
"advanced_styling_field_upper_label_color_description": "Färbt die kleinen Labels über Eingabefeldern und Skalenbeschriftungen ein.",
"advanced_styling_field_upper_label_size": "Label-Schriftgröße",
"advanced_styling_field_upper_label_size_description": "Skaliert die kleinen Labels über Eingabefeldern und Skalenbeschriftungen.",
"advanced_styling_field_upper_label_weight": "Label-Schriftstärke",
"advanced_styling_field_upper_label_weight_description": "Macht die Labels dünner oder fetter.",
"advanced_styling_section_buttons": "Buttons",
"advanced_styling_section_headlines": "Überschriften & Beschreibungen",
"advanced_styling_section_inputs": "Eingabefelder",
@@ -2436,7 +2446,9 @@
"verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten",
"verify_email_before_submission_button": "Überprüfen",
"verify_email_before_submission_description": "Um an dieser Umfrage teilzunehmen, bitte bestätige deine E-Mail",
"want_to_respond": "Möchtest Du antworten?"
"want_to_respond": "Möchtest Du antworten?",
"paused_heading": "Pausiert",
"completed_heading": "Abgeschlossen"
},
"setup": {
"intro": {
@@ -2871,6 +2883,11 @@
"gauge_feature_satisfaction_question_2_headline": "Was könnten wir besser machen?",
"identify_customer_goals_description": "Besser verstehen, ob deine Botschaften die richtigen Erwartungen an dein Produkt schaffen.",
"identify_customer_goals_name": "Kundenziele identifizieren",
"identify_customer_goals_question_1_choice_1": "Meine Nutzerbasis tiefgehend verstehen",
"identify_customer_goals_question_1_choice_2": "Upselling-Möglichkeiten identifizieren",
"identify_customer_goals_question_1_choice_3": "Das bestmögliche Produkt entwickeln",
"identify_customer_goals_question_1_choice_4": "Die Welt beherrschen, um allen Rosenkohl zum Frühstück zu servieren",
"identify_customer_goals_question_1_headline": "Was ist Ihr Hauptziel bei der Nutzung von $[projectName]?",
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldebarrieren zu gewinnen.",
"identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren",
"identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt",
@@ -2945,12 +2962,14 @@
"improve_trial_conversion_question_1_subheader": "Hilf uns, Dich besser zu verstehen:",
"improve_trial_conversion_question_2_button_label": "Weiter",
"improve_trial_conversion_question_2_headline": "Das tut mir leid zu hören. Was war das größte Problem bei der Nutzung von $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Weiter",
"improve_trial_conversion_question_3_headline": "Was haben Sie von $[projectName] erwartet?",
"improve_trial_conversion_question_4_button_label": "Erhalte 20% Rabatt",
"improve_trial_conversion_question_4_headline": "Das tut mir leid zu hören! Erhalte 20% Rabatt im ersten Jahr.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Wir freuen uns, dir einen 20% Rabatt auf einen Jahresplan anzubieten.</span></p>",
"improve_trial_conversion_question_5_button_label": "Weiter",
"improve_trial_conversion_question_5_headline": "Was möchtest Du erreichen?",
"improve_trial_conversion_question_5_subheader": "Bitte wähle eine der folgenden Optionen aus:",
"improve_trial_conversion_question_5_subheader": "Bitte beschreibe unten:",
"improve_trial_conversion_question_6_headline": "Wie löst Du dein Problem heutzutage?",
"improve_trial_conversion_question_6_subheader": "Bitte nenne alternative Lösungen:",
"integration_setup_survey_description": "Bewerte, wie einfach Nutzer Integrationen zu deinem Produkt hinzufügen können.",
+48 -29
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
"link_expired_description": "The link you used is no longer valid.",
"link_expired_heading": "Your link is expired."
},
"common": {
"accepted": "Accepted",
@@ -152,6 +153,7 @@
"centered_modal": "Centered Modal",
"change_organization": "Change organization",
"change_workspace": "Change workspace",
"choice_n": "Choice {{n}}",
"choices": "Choices",
"choose_environment": "Choose environment",
"choose_organization": "Choose organization",
@@ -165,6 +167,7 @@
"close": "Close",
"code": "Code",
"collapse_rows": "Collapse rows",
"column_n": "Column {{n}}",
"completed": "Completed",
"configuration": "Configuration",
"confirm": "Confirm",
@@ -236,6 +239,7 @@
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"field_placeholder": "{{field}} Placeholder",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
@@ -247,10 +251,12 @@
"generate": "Generate",
"go_back": "Go Back",
"go_to_dashboard": "Go to Dashboard",
"headline": "Headline",
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide_column": "Hide column",
"html": "HTML",
"id": "ID",
"image": "Image",
"images": "Images",
@@ -298,7 +304,6 @@
"months": "months",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
"my_product": "my Product",
"name": "Name",
"new": "New",
@@ -313,6 +318,7 @@
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"no_text_found": "No text found",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
@@ -336,7 +342,7 @@
"organization_settings": "Organization settings",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"other_placeholder": "Other Placeholder",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
@@ -354,7 +360,6 @@
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
@@ -381,6 +386,7 @@
"responses": "Responses",
"restart": "Restart",
"role": "Role",
"row_n": "Row {{n}}",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
@@ -419,6 +425,7 @@
"storage_not_configured": "File storage not set up, uploads will likely fail",
"string": "Text",
"styling": "Styling",
"subheader": "Subheader",
"submit": "Submit",
"summary": "Summary",
"survey": "Survey",
@@ -487,7 +494,6 @@
"workspace_name_placeholder": "e.g. Formbricks",
"workspaces": "Workspaces",
"years": "years",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {projectLimit} workspaces.",
@@ -828,6 +834,9 @@
},
"notion_integration_description": "Send data to your Notion database",
"please_select_a_survey_error": "Please select a survey",
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
"select_at_least_one_question_error": "Please select at least one question",
"slack": {
"already_connected_another_survey": "You have already connected another survey to this channel.",
@@ -1301,16 +1310,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "You are all set! Time to create your first survey",
"alphabetical": "Alphabetical",
"copy_survey": "Copy survey",
"copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
"activate_translations": "Activate translations",
"add": "Add +",
"add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey",
"add_a_four_digit_pin": "Add a four digit PIN",
@@ -1359,6 +1362,8 @@
"assign": "Assign =",
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Auto-advance in single-question blocks. Required questions hide Next, except when \"Other\" is selected.",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
@@ -1404,6 +1409,7 @@
"caution_text": "Changes will lead to inconsistencies",
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_default": "Change default",
"change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access",
"change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
@@ -1416,6 +1422,7 @@
"choose_where_to_run_the_survey": "Choose where to run the survey.",
"city": "City",
"close_survey_on_response_limit": "Close survey on response limit",
"code": "Code",
"color": "Color",
"column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.",
"columns": "Columns",
@@ -1440,6 +1447,7 @@
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"default_language": "Default language",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
@@ -1459,7 +1467,6 @@
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"element_not_found": "Question not found",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
@@ -1595,11 +1602,13 @@
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
"manage_languages": "Manage Languages",
"manage_languages": "Manage languages",
"manage_translations": "Manage translations",
"matrix_all_fields": "All fields",
"matrix_rows": "Rows",
"max_file_size": "Max file size",
"max_file_size_limit_is": "Max file size limit is",
"missing_first": "Missing first",
"move_question_to_block": "Move question to block",
"multiply": "Multiply *",
"needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance",
@@ -1607,7 +1616,7 @@
"next_button_label": "“Next” button label",
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.",
"no_images_found_for": "No images found for “{query}”",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
"no_languages_found_add_first_one_to_get_started": "No survey languages found in this workspace. Please add one to get started.",
"no_option_found": "No option found",
"no_recall_items_found": "No recall items found",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
@@ -1634,6 +1643,7 @@
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"present_your_survey_in_multiple_languages": "Present your survey in multiple languages",
"prevent_double_submission": "Prevent double submission",
"prevent_double_submission_description": "Only allow 1 response per email address",
"progress_saved": "Progress saved",
@@ -1725,6 +1735,7 @@
"seven_points": "7 points",
"show_block_settings": "Show Block settings",
"show_button": "Show Button",
"show_in_order": "Show in order",
"show_language_switch": "Show language switch",
"show_multiple_times": "Show a limited number of times",
"show_only_once": "Show only once",
@@ -1756,7 +1767,6 @@
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
"target_block_not_found": "Target block not found",
"targeted": "Targeted",
"ten_points": "10 points",
@@ -1764,9 +1774,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they do not respond.",
"then": "Then",
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
"this_will_remove_the_language_and_all_its_translations": "This will remove this language and all its translations from this survey. This action cannot be undone.",
"three_points": "3 points",
"times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
"translated": "Translated",
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired…",
"try_lollipop_or_mountain": "Try “lollipop” or “mountain”…",
"type_field_id": "Type field id",
@@ -1841,6 +1853,7 @@
"verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact",
"visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.",
"visible": "Visible",
"wait": "Wait",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey",
"waiting_time_across_surveys": "Cooldown Period (across surveys)",
@@ -2049,7 +2062,6 @@
"downloading_qr_code": "Downloading QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
"failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
@@ -2127,6 +2139,7 @@
"this_quarter": "This quarter",
"this_year": "This year",
"time_to_complete": "Time to Complete",
"ttc_survey_tooltip": "Average time to complete the survey.",
"ttc_tooltip": "Average time to complete the question.",
"unknown_question_type": "Unknown Question Type",
"use_personal_links": "Use personal links",
@@ -2136,7 +2149,6 @@
},
"survey_deleted_successfully": "Survey deleted successfully",
"survey_duplicated_successfully": "Survey duplicated successfully",
"survey_duplication_error": "Failed to duplicate the survey.",
"templates": {
"all_channels": "All channels",
"all_industries": "All industries",
@@ -2224,7 +2236,6 @@
"duplicate_language_or_language_id": "Duplicate language or language ID",
"edit_languages": "Edit languages",
"identifier": "Identifier (ISO)",
"incomplete_translations": "Incomplete translations",
"language": "Language",
"language_deleted_successfully": "Language deleted successfully",
"languages_updated_successfully": "Languages updated successfully",
@@ -2234,8 +2245,7 @@
"please_select_a_language": "Please select a language",
"remove_language": "Remove Language",
"remove_language_from_surveys_to_remove_it_from_workspace": "Please remove the language from these surveys in order to remove it from the workspace.",
"search_items": "Search items",
"translate": "Translate"
"search_items": "Search items"
},
"look": {
"add_background_color": "Add background color",
@@ -2295,12 +2305,12 @@
"advanced_styling_field_track_bg_description": "Colors the unfilled portion of the bar.",
"advanced_styling_field_track_height": "Track Height",
"advanced_styling_field_track_height_description": "Controls the progress bar thickness.",
"advanced_styling_field_upper_label_color": "Headline Label Color",
"advanced_styling_field_upper_label_color_description": "Colors the small label above inputs.",
"advanced_styling_field_upper_label_size": "Headline Label Font Size",
"advanced_styling_field_upper_label_size_description": "Scales the small label above inputs.",
"advanced_styling_field_upper_label_weight": "Headline Label Font Weight",
"advanced_styling_field_upper_label_weight_description": "Makes the label lighter or bolder.",
"advanced_styling_field_upper_label_color": "Label Color",
"advanced_styling_field_upper_label_color_description": "Colors the small labels above inputs and scale labels.",
"advanced_styling_field_upper_label_size": "Label Font Size",
"advanced_styling_field_upper_label_size_description": "Scales the small labels above inputs and scale labels.",
"advanced_styling_field_upper_label_weight": "Label Font Weight",
"advanced_styling_field_upper_label_weight_description": "Makes the labels lighter or bolder.",
"advanced_styling_section_buttons": "Buttons",
"advanced_styling_section_headlines": "Headlines & Descriptions",
"advanced_styling_section_inputs": "Inputs",
@@ -2436,7 +2446,9 @@
"verify_email_before_submission": "Verify your email to respond",
"verify_email_before_submission_button": "Verify",
"verify_email_before_submission_description": "To respond to this survey, please verify your email",
"want_to_respond": "Want to respond?"
"want_to_respond": "Want to respond?",
"paused_heading": "Paused",
"completed_heading": "Completed"
},
"setup": {
"intro": {
@@ -2871,6 +2883,11 @@
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?",
"identify_customer_goals_description": "Better understand if your messaging creates the right expectations of the value your product provides.",
"identify_customer_goals_name": "Identify Customer Goals",
"identify_customer_goals_question_1_choice_1": "Understand my user base deeply",
"identify_customer_goals_question_1_choice_2": "Identify upselling opportunities",
"identify_customer_goals_question_1_choice_3": "Build the best possible product",
"identify_customer_goals_question_1_choice_4": "Rule the world to make everyone breakfast brussels sprouts",
"identify_customer_goals_question_1_headline": "What is your primary goal for using $[projectName]?",
"identify_sign_up_barriers_description": "Offer a discount to gather insights about sign up barriers.",
"identify_sign_up_barriers_name": "Identify Sign Up Barriers",
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
@@ -2945,12 +2962,14 @@
"improve_trial_conversion_question_1_subheader": "Help us understand you better:",
"improve_trial_conversion_question_2_button_label": "Next",
"improve_trial_conversion_question_2_headline": "Sorry to hear. What was the biggest problem using $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Next",
"improve_trial_conversion_question_3_headline": "What did you expect $[projectName] to do?",
"improve_trial_conversion_question_4_button_label": "Get 20% off",
"improve_trial_conversion_question_4_headline": "Sorry to hear! Get 20% off the first year.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We are happy to offer you a 20% discount on a yearly plan.</span></p>",
"improve_trial_conversion_question_5_button_label": "Next",
"improve_trial_conversion_question_5_headline": "What would you like to achieve?",
"improve_trial_conversion_question_5_subheader": "Please select one of the following options:",
"improve_trial_conversion_question_5_subheader": "Please describe below:",
"improve_trial_conversion_question_6_headline": "How are you solving your problem now?",
"improve_trial_conversion_question_6_subheader": "Please name alternative solutions:",
"integration_setup_survey_description": "Evaluate how easily users can add integrations to your product. Find blind spots.",
+47 -28
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Tu enlace ha caducado.",
"link_expired_description": "El enlace que has utilizado ya no es válido."
"link_expired_description": "El enlace que has utilizado ya no es válido.",
"link_expired_heading": "Tu enlace ha caducado."
},
"common": {
"accepted": "Aceptado",
@@ -152,6 +153,7 @@
"centered_modal": "Modal centrado",
"change_organization": "Cambiar organización",
"change_workspace": "Cambiar espacio de trabajo",
"choice_n": "Opción {{n}}",
"choices": "Opciones",
"choose_environment": "Elegir entorno",
"choose_organization": "Elegir organización",
@@ -165,6 +167,7 @@
"close": "Cerrar",
"code": "Código",
"collapse_rows": "Contraer filas",
"column_n": "Columna {{n}}",
"completed": "Completado",
"configuration": "Configuración",
"confirm": "Confirmar",
@@ -236,6 +239,7 @@
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"field_placeholder": "Marcador de posición de {{field}}",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
@@ -247,10 +251,12 @@
"generate": "Generar",
"go_back": "Volver",
"go_to_dashboard": "Ir al panel de control",
"headline": "Titular",
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide_column": "Ocultar columna",
"html": "HTML",
"id": "ID",
"image": "Imagen",
"images": "Imágenes",
@@ -298,7 +304,6 @@
"months": "meses",
"move_down": "Mover hacia abajo",
"move_up": "Mover hacia arriba",
"multiple_languages": "Múltiples idiomas",
"my_product": "mi producto",
"name": "Nombre",
"new": "Nuevo",
@@ -313,6 +318,7 @@
"no_result_found": "No se encontró resultado",
"no_results": "Sin resultados",
"no_surveys_found": "No se encontraron encuestas.",
"no_text_found": "No se encontró texto",
"none_of_the_above": "Ninguna de las anteriores",
"not_authenticated": "No estás autenticado para realizar esta acción.",
"not_authorized": "No autorizado",
@@ -336,7 +342,7 @@
"organization_settings": "Ajustes de la organización",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"other_placeholder": "Otro marcador de posición",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
@@ -354,7 +360,6 @@
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
"product_manager": "Gestor de producto",
"production": "Producción",
@@ -381,6 +386,7 @@
"responses": "Respuestas",
"restart": "Reiniciar",
"role": "Rol",
"row_n": "Fila {{n}}",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
@@ -419,6 +425,7 @@
"storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas",
"string": "Texto",
"styling": "Estilo",
"subheader": "Subtítulo",
"submit": "Enviar",
"summary": "Resumen",
"survey": "Encuesta",
@@ -487,7 +494,6 @@
"workspace_name_placeholder": "p. ej. Formbricks",
"workspaces": "Proyectos",
"years": "años",
"you": "Tú",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
@@ -828,6 +834,9 @@
},
"notion_integration_description": "Envía datos a tu base de datos de Notion",
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
"slack": {
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
@@ -1301,16 +1310,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "¡Todo listo! Es hora de crear tu primera encuesta",
"alphabetical": "Alfabético",
"copy_survey": "Copiar encuesta",
"copy_survey_description": "Copia esta encuesta a otro entorno",
"copy_survey_error": "Error al copiar la encuesta",
"copy_survey_link_to_clipboard": "Copiar enlace de la encuesta al portapapeles",
"copy_survey_partially_success": "{success} encuestas copiadas correctamente, {error} fallidas.",
"copy_survey_success": "¡Encuesta copiada correctamente!",
"delete_survey_and_responses_warning": "¿Estás seguro de que quieres eliminar esta encuesta y todas sus respuestas?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Elige el idioma predeterminado para esta encuesta:",
"2_activate_translation_for_specific_languages": "2. Activa la traducción para idiomas específicos:",
"activate_translations": "Activar traducciones",
"add": "Añadir +",
"add_a_delay_or_auto_close_the_survey": "Añadir un retraso o cerrar automáticamente la encuesta",
"add_a_four_digit_pin": "Añadir un PIN de cuatro dígitos",
@@ -1359,6 +1362,8 @@
"assign": "Asignar =",
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avance automático en bloques de una sola pregunta. Las preguntas obligatorias ocultan Siguiente, excepto cuando se selecciona \"Otro\".",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
@@ -1404,6 +1409,7 @@
"caution_text": "Los cambios provocarán inconsistencias",
"change_anyway": "Cambiar de todos modos",
"change_background": "Cambiar fondo",
"change_default": "Cambiar predeterminado",
"change_question_type": "Cambiar tipo de pregunta",
"change_survey_type": "Cambiar el tipo de encuesta afecta al acceso existente",
"change_the_background_to_a_color_image_or_animation": "Cambiar el fondo a un color, imagen o animación.",
@@ -1416,6 +1422,7 @@
"choose_where_to_run_the_survey": "Elige dónde ejecutar la encuesta.",
"city": "Ciudad",
"close_survey_on_response_limit": "Cerrar encuesta al alcanzar el límite de respuestas",
"code": "Código",
"color": "Color",
"column_used_in_logic_error": "Esta columna se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"columns": "Columnas",
@@ -1440,6 +1447,7 @@
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"default_language": "Idioma predeterminado",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
"delete_choice": "Eliminar opción",
@@ -1459,7 +1467,6 @@
"duplicate_question": "Duplicar pregunta",
"edit_link": "Editar enlace",
"edit_recall": "Editar recuperación",
"edit_translations": "Editar traducciones de {lang}",
"element_not_found": "Pregunta no encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir a los participantes cambiar el idioma de la encuesta en cualquier momento durante la encuesta.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protección contra spam utiliza reCAPTCHA v3 para filtrar las respuestas spam.",
@@ -1596,10 +1603,12 @@
"long_answer_toggle_description": "Permitir a los encuestados escribir respuestas más largas y de varias líneas.",
"lower_label": "Etiqueta inferior",
"manage_languages": "Gestionar idiomas",
"manage_translations": "Gestionar traducciones",
"matrix_all_fields": "Todos los campos",
"matrix_rows": "Filas",
"max_file_size": "Tamaño máximo de archivo",
"max_file_size_limit_is": "El límite de tamaño máximo de archivo es",
"missing_first": "Faltantes primero",
"move_question_to_block": "Mover pregunta al bloque",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necesario para una instancia Cal.com autohospedada",
@@ -1607,7 +1616,7 @@
"next_button_label": "Etiqueta del botón \"Siguiente\"",
"no_hidden_fields_yet_add_first_one_below": "Aún no hay campos ocultos. Añade el primero a continuación.",
"no_images_found_for": "No se encontraron imágenes para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas. Añade el primero para comenzar.",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas de encuesta en este espacio de trabajo. Por favor, añade uno para comenzar.",
"no_option_found": "No se encontró ninguna opción",
"no_recall_items_found": "No se encontraron elementos de recuperación",
"no_variables_yet_add_first_one_below": "No hay variables todavía. Añade la primera a continuación.",
@@ -1634,6 +1643,7 @@
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
"please_specify": "Por favor, especifica",
"present_your_survey_in_multiple_languages": "Presenta tu encuesta en varios idiomas",
"prevent_double_submission": "Evitar envío duplicado",
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
"progress_saved": "Progreso guardado",
@@ -1725,6 +1735,7 @@
"seven_points": "7 puntos",
"show_block_settings": "Mostrar ajustes del bloque",
"show_button": "Mostrar botón",
"show_in_order": "Mostrar en orden",
"show_language_switch": "Mostrar cambio de idioma",
"show_multiple_times": "Mostrar un número limitado de veces",
"show_only_once": "Mostrar solo una vez",
@@ -1756,7 +1767,6 @@
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
"target_block_not_found": "Bloque objetivo no encontrado",
"targeted": "Dirigido",
"ten_points": "10 puntos",
@@ -1764,9 +1774,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
"then": "Entonces",
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
"this_will_remove_the_language_and_all_its_translations": "Esto eliminará este idioma y todas sus traducciones de esta encuesta. Esta acción no se puede deshacer.",
"three_points": "3 puntos",
"times": "veces",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
"translated": "Traducido",
"trigger_survey_when_one_of_the_actions_is_fired": "Activar encuesta cuando se dispare una de las acciones...",
"try_lollipop_or_mountain": "Prueba 'piruleta' o 'montaña'...",
"type_field_id": "Escribe el id del campo",
@@ -1841,6 +1853,7 @@
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
"visibility_and_recontact": "Visibilidad y recontacto",
"visibility_and_recontact_description": "Controla cuándo puede aparecer esta encuesta y con qué frecuencia puede volver a aparecer.",
"visible": "Visible",
"wait": "Esperar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta",
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
@@ -2049,7 +2062,6 @@
"downloading_qr_code": "Descargando código QR",
"drop_offs": "Abandonos",
"drop_offs_tooltip": "Número de veces que se ha iniciado la encuesta pero no se ha completado.",
"failed_to_copy_link": "Error al copiar el enlace",
"filter_added_successfully": "Filtro añadido correctamente",
"filter_updated_successfully": "Filtro actualizado correctamente",
"filtered_responses_csv": "Respuestas filtradas (CSV)",
@@ -2127,6 +2139,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este año",
"time_to_complete": "Tiempo para completar",
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
"unknown_question_type": "Tipo de pregunta desconocido",
"use_personal_links": "Usar enlaces personales",
@@ -2136,7 +2149,6 @@
},
"survey_deleted_successfully": "¡Encuesta eliminada correctamente!",
"survey_duplicated_successfully": "Encuesta duplicada correctamente.",
"survey_duplication_error": "Error al duplicar la encuesta.",
"templates": {
"all_channels": "Todos los canales",
"all_industries": "Todas las industrias",
@@ -2224,7 +2236,6 @@
"duplicate_language_or_language_id": "Idioma o ID de idioma duplicado",
"edit_languages": "Editar idiomas",
"identifier": "Identificador (ISO)",
"incomplete_translations": "Traducciones incompletas",
"language": "Idioma",
"language_deleted_successfully": "Idioma eliminado correctamente",
"languages_updated_successfully": "Idiomas actualizados correctamente",
@@ -2234,8 +2245,7 @@
"please_select_a_language": "Por favor, selecciona un idioma",
"remove_language": "Eliminar idioma",
"remove_language_from_surveys_to_remove_it_from_workspace": "Por favor, elimina el idioma de estas encuestas para poder eliminarlo del espacio de trabajo.",
"search_items": "Buscar elementos",
"translate": "Traducir"
"search_items": "Buscar elementos"
},
"look": {
"add_background_color": "Añadir color de fondo",
@@ -2295,12 +2305,12 @@
"advanced_styling_field_track_bg_description": "Colorea la parte no rellenada de la barra.",
"advanced_styling_field_track_height": "Altura de la pista",
"advanced_styling_field_track_height_description": "Controla el grosor de la barra de progreso.",
"advanced_styling_field_upper_label_color": "Color de la etiqueta del titular",
"advanced_styling_field_upper_label_color_description": "Colorea la etiqueta pequeña sobre los campos de entrada.",
"advanced_styling_field_upper_label_size": "Tamaño de fuente de la etiqueta del titular",
"advanced_styling_field_upper_label_size_description": "Escala la etiqueta pequeña sobre los campos de entrada.",
"advanced_styling_field_upper_label_weight": "Grosor de fuente de la etiqueta del titular",
"advanced_styling_field_upper_label_weight_description": "Hace que la etiqueta sea más ligera o más gruesa.",
"advanced_styling_field_upper_label_color": "Color de etiqueta",
"advanced_styling_field_upper_label_color_description": "Colorea las pequeñas etiquetas sobre los campos de entrada y las etiquetas de escala.",
"advanced_styling_field_upper_label_size": "Tamaño de fuente de etiqueta",
"advanced_styling_field_upper_label_size_description": "Escala las pequeñas etiquetas sobre los campos de entrada y las etiquetas de escala.",
"advanced_styling_field_upper_label_weight": "Grosor de fuente de etiqueta",
"advanced_styling_field_upper_label_weight_description": "Hace que las etiquetas sean más ligeras o más gruesas.",
"advanced_styling_section_buttons": "Botones",
"advanced_styling_section_headlines": "Títulos y descripciones",
"advanced_styling_section_inputs": "Campos de entrada",
@@ -2436,7 +2446,9 @@
"verify_email_before_submission": "Verifica tu correo electrónico para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a esta encuesta, por favor verifica tu correo electrónico",
"want_to_respond": "¿Quieres responder?"
"want_to_respond": "¿Quieres responder?",
"paused_heading": "Pausado",
"completed_heading": "Completado"
},
"setup": {
"intro": {
@@ -2871,6 +2883,11 @@
"gauge_feature_satisfaction_question_2_headline": "¿Qué es una cosa que podríamos mejorar?",
"identify_customer_goals_description": "Comprende mejor si tus mensajes crean las expectativas correctas sobre el valor que proporciona tu producto.",
"identify_customer_goals_name": "Identificar objetivos del cliente",
"identify_customer_goals_question_1_choice_1": "Comprender en profundidad a mi base de usuarios",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de venta adicional",
"identify_customer_goals_question_1_choice_3": "Construir el mejor producto posible",
"identify_customer_goals_question_1_choice_4": "Conquistar el mundo para que todos desayunen coles de Bruselas",
"identify_customer_goals_question_1_headline": "¿Cuál es tu objetivo principal al usar $[projectName]?",
"identify_sign_up_barriers_description": "Ofrece un descuento para obtener información sobre las barreras de registro.",
"identify_sign_up_barriers_name": "Identificar barreras de registro",
"identify_sign_up_barriers_question_1_button_label": "Obtener 10 % de descuento",
@@ -2945,12 +2962,14 @@
"improve_trial_conversion_question_1_subheader": "Ayúdanos a entenderte mejor:",
"improve_trial_conversion_question_2_button_label": "Siguiente",
"improve_trial_conversion_question_2_headline": "Lamentamos oír eso. ¿Cuál fue el mayor problema al usar $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Siguiente",
"improve_trial_conversion_question_3_headline": "¿Qué esperabas que hiciera $[projectName]?",
"improve_trial_conversion_question_4_button_label": "Obtener 20 % de descuento",
"improve_trial_conversion_question_4_headline": "¡Sentimos oírlo! Obtén un 20 % de descuento en el primer año.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nos complace ofrecerte un 20 % de descuento en un plan anual.</span></p>",
"improve_trial_conversion_question_5_button_label": "Siguiente",
"improve_trial_conversion_question_5_headline": "¿Qué te gustaría conseguir?",
"improve_trial_conversion_question_5_subheader": "Por favor, selecciona una de las siguientes opciones:",
"improve_trial_conversion_question_5_subheader": "Por favor, describe a continuación:",
"improve_trial_conversion_question_6_headline": "¿Cómo estás solucionando tu problema ahora?",
"improve_trial_conversion_question_6_subheader": "Por favor, nombra soluciones alternativas:",
"integration_setup_survey_description": "Evalúa con qué facilidad los usuarios pueden añadir integraciones a tu producto. Encuentra puntos ciegos.",

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