Compare commits

..

62 Commits

Author SHA1 Message Date
Harsh Bhat 3a37d7279f docs: add docs for pretty url 2026-05-05 12:49:22 +05:30
Tiago fae00f6a82 fix: outlook preview (#7803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-04 15:10:14 +00:00
Anshuman Pandey a274c444ad fix: reject SSO auto-provisioning when AUTH_SSO_DEFAULT_TEAM_ID is missing (#7926) 2026-05-04 10:57:44 +00:00
Anshuman Pandey 5fae207cd7 fix: duplicate action class name error (#7919) 2026-04-30 09:17:09 +00:00
Johannes 654539d320 fix: removed dead menu item & theme import (#7909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 08:09:40 +00:00
Johannes 44aac89d41 fix: return generic credentials error for SSO-only accounts (#7911)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-30 08:09:34 +00:00
Johannes e0250b2a58 fix: include partial response in trigger description (#7908)
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-30 06:27:48 +00:00
Labeeb a8d6cd8a9f fix: 7817 use fully translated inactive survey headings (#7836) 2026-04-30 04:46:13 +00:00
Bhagya Amarasinghe f79fe1490e feat: add PostHog experiment wrappers (#7899) 2026-04-29 08:07:11 +00:00
Dhruwang Jariwala 5cfbc671c5 fix: allow back navigation to prefilled questions in email embed surveys (#7900) 2026-04-29 08:06:08 +00:00
Tiago e6e9419b93 fix: require step-up authorization for account deletion (#7901)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-28 12:46:27 +00:00
dependabot[bot] 90eb78e571 chore(deps): bump the npm_and_yarn group across 5 directories with 2 updates (#7834)
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-28 12:01:12 +02:00
Johannes 991866f549 docs: document option ID prefilling (#7893)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-28 08:17:05 +00:00
Bhagya Amarasinghe 6d1b3475d4 fix(survey-list): reduce v3 overview query cost (#7812) 2026-04-27 16:53:53 +00:00
Johannes 5e967a2b67 fix: use app.formbricks.com in v1 API docs (#7894)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-27 13:19:12 +00:00
Johannes 23a8fd6a47 fix: replace organization name placeholder example (#7832)
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-27 12:49:16 +00:00
Dhruwang Jariwala 0686eb3cbb feat: add refetch button to refresh responses table (#7808)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-27 10:10:53 +00:00
Anshuman Pandey 6d9ab315c2 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 08:50:21 +00: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
385 changed files with 24823 additions and 9884 deletions
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite" using: "composite"
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build - name: Cache Build
uses: actions/cache@v3 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: cache-build id: cache-build
env: env:
cache-name: prod-build cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash shell: bash
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 20.x node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true' if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true' if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies - 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' if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite" using: "composite"
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2 fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Install dependencies - 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 - name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4 uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+37 -48
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 22.x node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - 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 shell: bash
- name: create .env - name: create .env
@@ -85,65 +85,48 @@ jobs:
echo "S3_REGION=us-east-1" >> .env echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash shell: bash
- name: Install MinIO client (mc) - name: Start RustFS Server
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
run: | run: |
set -euo pipefail set -euo pipefail
# Start MinIO server in background # Start RustFS server in background
docker run -d \ docker run -d \
--name minio-server \ --name rustfs-server \
-p 9000:9000 \ -p 9000:9000 \
-p 9001:9001 \ -p 9001:9001 \
-e MINIO_ROOT_USER=devminio \ -e RUSTFS_ACCESS_KEY=devrustfs \
-e MINIO_ROOT_PASSWORD=devminio123 \ -e RUSTFS_SECRET_KEY=devrustfs123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \ -e RUSTFS_ADDRESS=:9000 \
server /data --console-address :9001 -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: | run: |
set -euo pipefail set -euo pipefail
echo "Waiting for MinIO to be ready..." docker run --rm \
ready=0 --network host \
for i in {1..60}; do --entrypoint /bin/sh \
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then -e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
echo "MinIO is up after ${i} seconds" -e RUSTFS_ADMIN_USER=devrustfs \
ready=1 -e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
break -e RUSTFS_SERVICE_USER=devrustfs-service \
fi -e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
sleep 1 -e RUSTFS_BUCKET_NAME=formbricks-e2e \
done -e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
if [ "$ready" -ne 1 ]; then -v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
echo "::error::MinIO did not become ready within 60 seconds" minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
exit 1 /tmp/rustfs-init.sh
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App - name: Build App
run: | run: |
@@ -242,8 +225,14 @@ jobs:
if: failure() if: failure()
with: with:
name: app-logs name: app-logs
if-no-files-found: ignore
path: app.log path: app.log
- name: Output App Logs - name: Output App Logs
if: failure() 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 - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 20.x node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - 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 - name: create .env
run: cp .env.example .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 fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 22.x node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - 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 - name: create .env
run: cp .env.example .env run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 20.x node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - 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 - name: create .env
run: cp .env.example .env run: cp .env.example .env
+3 -2
View File
@@ -2,6 +2,7 @@ name: Translation Validation
permissions: permissions:
contents: read contents: read
pull-requests: read
on: on:
pull_request: pull_request:
@@ -39,7 +40,7 @@ jobs:
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true' if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 22.x node-version: 22.x
@@ -49,7 +50,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.changes.outputs.translations == 'true' 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 - name: Validate translation keys
if: steps.changes.outputs.translations == 'true' if: steps.changes.outputs.translations == 'true'
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.2.17", "@storybook/addon-a11y": "10.3.5",
"@storybook/addon-links": "10.2.17", "@storybook/addon-docs": "10.3.5",
"@storybook/addon-onboarding": "10.2.17", "@storybook/addon-links": "10.3.5",
"@storybook/react-vite": "10.2.17", "@storybook/addon-onboarding": "10.3.5",
"@typescript-eslint/eslint-plugin": "8.57.0", "@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "4.2.1", "@tailwindcss/vite": "4.2.4",
"@typescript-eslint/parser": "8.57.0", "@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17", "eslint-plugin-storybook": "10.3.5",
"storybook": "10.2.17", "storybook": "10.3.5",
"vite": "7.3.1", "vite": "7.3.2"
"@storybook/addon-docs": "10.2.17"
} }
} }
@@ -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 { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => { test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce( 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); await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
}); });
@@ -1,6 +1,6 @@
"use server"; "use server";
import { Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name, name: team.name,
})); }));
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
} }
@@ -409,16 +409,22 @@ export const MainNavigation = ({
: `/environments/${environment.id}/surveys/`; : `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => { const handleProjectChange = (projectId: string) => {
if (projectId === project.id) return; const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
startTransition(() => { startTransition(() => {
router.push(`/workspaces/${projectId}/`); setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
}); });
}; };
const handleOrganizationChange = (organizationId: string) => { const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return; const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => { startTransition(() => {
router.push(`/organizations/${organizationId}/`); setIsOrganizationDropdownOpen(false);
router.push(targetPath);
}); });
}; };
@@ -475,7 +481,7 @@ export const MainNavigation = ({
); );
const switcherIconClasses = 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; const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
return ( return (
@@ -114,8 +114,12 @@ export const OrganizationBreadcrumb = ({
} }
const handleOrganizationChange = (organizationId: string) => { const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => { startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentEnvironmentId) {
router.push(`/environments/${currentEnvironmentId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`); router.push(`/organizations/${organizationId}/`);
}); });
}; };
@@ -152,9 +152,13 @@ export const ProjectBreadcrumb = ({
} }
const handleProjectChange = (projectId: string) => { const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return; const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
startTransition(() => { startTransition(() => {
router.push(`/workspaces/${projectId}/`); setIsProjectDropdownOpen(false);
router.push(targetPath);
}); });
}; };
@@ -3,25 +3,22 @@
import { InboxIcon, PresentationIcon } from "lucide-react"; import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next"; 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 { 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"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface SurveyAnalysisNavigationProps { interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
activeId: string; activeId: string;
} }
export const SurveyAnalysisNavigation = ({ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
environmentId,
survey,
activeId,
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useTranslation(); 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 = [ const navigation = [
{ {
@@ -31,7 +28,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/summary?referer=true`, href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"), current: pathname?.includes("/summary"),
onClick: () => { onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id); revalidateSurveyIdPath(environment.id, survey.id);
}, },
}, },
{ {
@@ -41,7 +38,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/responses?referer=true`, href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"), current: pathname?.includes("/responses"),
onClick: () => { onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id); revalidateSurveyIdPath(environment.id, survey.id);
}, },
}, },
]; ];
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { createContext, useCallback, useContext, useState } from "react"; import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { import {
ElementOption, ElementOption,
ElementOptions, ElementOptions,
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
export interface DateRange { export interface DateRange {
from: Date | undefined; from: Date | undefined;
to?: Date | undefined; to?: Date;
} }
interface FilterDateContextProps { interface FilterDateContextProps {
@@ -41,6 +41,8 @@ interface FilterDateContextProps {
dateRange: DateRange; dateRange: DateRange;
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>; setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
resetState: () => void; resetState: () => void;
refreshAnalysisData: () => Promise<void>;
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
} }
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined); const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
@@ -61,6 +63,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
from: undefined, from: undefined,
to: getTodayDate(), to: getTodayDate(),
}); });
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
const resetState = useCallback(() => { const resetState = useCallback(() => {
setDateRange({ setDateRange({
@@ -73,20 +76,43 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
}); });
}, []); }, []);
return ( const refreshAnalysisData = useCallback(async () => {
<ResponseFilterContext.Provider await refreshHandlerRef.current?.();
value={{ }, []);
setSelectedFilter,
selectedFilter, const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
selectedOptions, refreshHandlerRef.current = handler;
setSelectedOptions,
dateRange, return () => {
setDateRange, if (refreshHandlerRef.current === handler) {
resetState, refreshHandlerRef.current = null;
}}> }
{children} };
</ResponseFilterContext.Provider> }, []);
const contextValue = useMemo(
() => ({
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
refreshAnalysisData,
registerAnalysisRefreshHandler,
}),
[
dateRange,
refreshAnalysisData,
registerAnalysisRefreshHandler,
resetState,
selectedFilter,
selectedOptions,
]
); );
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
}; };
const useResponseFilter = () => { const useResponseFilter = () => {
@@ -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;
@@ -2,6 +2,8 @@
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota"; import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses"; import { TResponseWithQuotas } from "@formbricks/types/responses";
@@ -13,6 +15,7 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"; import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
interface ResponsePageProps { interface ResponsePageProps {
@@ -46,8 +49,8 @@ export const ResponsePage = ({
const [page, setPage] = useState<number | null>(null); const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage); const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false); const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState } = useResponseFilter(); const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { t } = useTranslation();
const filters = useMemo( const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange), () => getFormattedFilters(survey, selectedFilter, dateRange),
@@ -86,6 +89,34 @@ export const ResponsePage = ({
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r))); setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
}; };
const refetchResponses = useCallback(async () => {
setIsFetchingFirstPage(true);
try {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
if (getResponsesActionResponse?.serverError) {
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
}
const freshResponses = getResponsesActionResponse?.data ?? [];
setResponses(freshResponses);
setPage(1);
setHasMore(freshResponses.length >= responsesPerPage);
} finally {
setIsFetchingFirstPage(false);
}
}, [filters, responsesPerPage, surveyId]);
useEffect(() => {
return registerAnalysisRefreshHandler(refetchResponses);
}, [refetchResponses, registerAnalysisRefreshHandler]);
const surveyMemoized = useMemo(() => { const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default"); return replaceHeadlineRecall(survey, "default");
}, [survey]); }, [survey]);
@@ -134,6 +165,8 @@ export const ResponsePage = ({
} }
}; };
fetchFilteredResponses(); fetchFilteredResponses();
// page is intentionally omitted to avoid refetching after the initial page setup.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]); }, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return ( return (
@@ -1,5 +1,4 @@
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { capitalize } from "lodash";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -9,6 +8,7 @@ import {
SmartphoneIcon, SmartphoneIcon,
} from "lucide-react"; } from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses"; import { TResponseMeta } from "@formbricks/types/responses";
import { capitalize } from "@/lib/utils/object";
export const getAddressFieldLabel = (field: string, t: TFunction) => { export const getAddressFieldLabel = (field: string, t: TFunction) => {
switch (field) { 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} pageTitle={survey.name}
cta={ cta={
<SurveyAnalysisCTA <SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
user={user} user={user}
publicDomain={publicDomain} publicDomain={publicDomain}
@@ -76,7 +74,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isStorageConfigured={IS_STORAGE_CONFIGURED} isStorageConfigured={IS_STORAGE_CONFIGURED}
/> />
}> }>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" /> <SurveyAnalysisNavigation activeId="responses" />
</PageHeader> </PageHeader>
<ResponsePage <ResponsePage
environment={environment} environment={environment}
@@ -4,16 +4,13 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { TSurvey } from "@formbricks/types/surveys/types"; import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Confetti } from "@/modules/ui/components/confetti"; import { Confetti } from "@/modules/ui/components/confetti";
interface SummaryMetadataProps { export const SuccessMessage = () => {
environment: TEnvironment; const { environment } = useEnvironment();
survey: TSurvey; const { survey } = useSurvey();
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false); const [confetti, setConfetti] = useState(false);
@@ -71,7 +71,7 @@ export const SummaryPage = ({
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined); const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary); const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter(); const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]); const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false); const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
@@ -111,7 +111,7 @@ export const SummaryPage = ({
} finally { } finally {
setIsDisplaysLoading(false); setIsDisplaysLoading(false);
} }
}, [fetchDisplays, t]); }, [fetchDisplays]);
const handleLoadMoreDisplays = useCallback(async () => { const handleLoadMoreDisplays = useCallback(async () => {
try { try {
@@ -131,13 +131,39 @@ export const SummaryPage = ({
} }
}, [tab, loadInitialDisplays]); }, [tab, loadInitialDisplays]);
const fetchSummary = useCallback(async () => {
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
const updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
if (updatedSurveySummary?.serverError) {
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
}
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
}, [dateRange, selectedFilter, survey, surveyId]);
const refreshSummary = useCallback(async () => {
setIsLoading(true);
try {
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
} finally {
setIsLoading(false);
}
}, [fetchSummary, loadInitialDisplays, tab]);
useEffect(() => {
return registerAnalysisRefreshHandler(refreshSummary);
}, [refreshSummary, registerAnalysisRefreshHandler]);
// Only fetch data when filters change or when there's no initial data // Only fetch data when filters change or when there's no initial data
useEffect(() => { useEffect(() => {
// If we have initial data and no filters are applied, don't fetch // If we have initial data and no filters are applied, don't fetch
const hasNoFilters = const hasNoFilters =
(!selectedFilter || (!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!dateRange || (!dateRange.from && !dateRange.to)); (!dateRange || (!dateRange.from && !dateRange.to));
if (initialSurveySummary && hasNoFilters) { if (initialSurveySummary && hasNoFilters) {
@@ -145,21 +171,11 @@ export const SummaryPage = ({
return; return;
} }
const fetchSummary = async () => { const fetchFilteredSummary = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// Recalculate filters inside the effect to ensure we have the latest values await fetchSummary();
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
@@ -167,8 +183,8 @@ export const SummaryPage = ({
} }
}; };
fetchSummary(); fetchFilteredSummary();
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]); }, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
const surveyMemoized = useMemo(() => { const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default"); return replaceHeadlineRecall(survey, "default");
@@ -1,18 +1,18 @@
"use client"; "use client";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react"; import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment"; import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage"; 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 { 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 { 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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog"; import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId"; import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
@@ -23,8 +23,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions"; import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps { interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean; isReadOnly: boolean;
user: TUser; user: TUser;
publicDomain: string; publicDomain: string;
@@ -41,8 +39,6 @@ interface ModalState {
} }
export const SurveyAnalysisCTA = ({ export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly, isReadOnly,
user, user,
publicDomain, publicDomain,
@@ -63,9 +59,12 @@ export const SurveyAnalysisCTA = ({
}); });
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const { project } = useEnvironment(); const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const { refreshAnalysisData } = useResponseFilter();
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -77,7 +76,7 @@ export const SurveyAnalysisCTA = ({
}, [searchParams]); }, [searchParams]);
const handleShareModalToggle = (open: boolean) => { const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(globalThis.location.search);
const currentShareParam = params.get("share") === "true"; const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) { if (open && !currentShareParam) {
@@ -147,6 +146,25 @@ export const SurveyAnalysisCTA = ({
}; };
const iconActions = [ const iconActions = [
{
icon: RefreshCcwIcon,
tooltip: t("common.refresh"),
onClick: async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
await refreshAnalysisData();
toast.success(t("common.data_refreshed_successfully"));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
} finally {
setIsRefreshing(false);
}
},
disabled: isRefreshing,
isVisible: true,
},
{ {
icon: BellRing, icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"), tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -183,7 +201,7 @@ export const SurveyAnalysisCTA = ({
return ( return (
<div className="hidden justify-end gap-x-1.5 sm:flex"> <div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && ( {!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown environment={environment} survey={survey} /> <SurveyStatusDropdown />
)} )}
<IconBar actions={iconActions} /> <IconBar actions={iconActions} />
@@ -215,7 +233,7 @@ export const SurveyAnalysisCTA = ({
projectCustomScripts={project.customHeadScripts} projectCustomScripts={project.customHeadScripts}
/> />
)} )}
<SuccessMessage environment={environment} survey={survey} /> <SuccessMessage />
{responseCount > 0 && ( {responseCount > 0 && (
<EditPublicSurveyAlertDialog <EditPublicSurveyAlertDialog
@@ -2,7 +2,7 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react"; import { CopyIcon, SendIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthenticationError } from "@formbricks/types/errors"; import { AuthenticationError } from "@formbricks/types/errors";
@@ -21,6 +21,7 @@ interface EmailTabProps {
export const EmailTab = ({ surveyId, email }: EmailTabProps) => { export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview"); const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>(""); const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
const { t } = useTranslation(); const { t } = useTranslation();
const emailHtml = useMemo(() => { const emailHtml = useMemo(() => {
@@ -31,6 +32,40 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
.replaceAll("?preview=true", ""); .replaceAll("?preview=true", "");
}, [emailHtmlPreview]); }, [emailHtmlPreview]);
const sanitizedEmailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
}, [emailHtmlPreview]);
const emailPreviewDocument = useMemo(() => {
if (!sanitizedEmailHtml) return "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<base target="_blank" />
<style>
:root {
color-scheme: only light;
supported-color-schemes: light;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color-scheme: only light;
}
</style>
</head>
<body>${sanitizedEmailHtml}</body>
</html>`;
}, [sanitizedEmailHtml]);
const tabs = [ const tabs = [
{ {
id: "preview", id: "preview",
@@ -51,6 +86,25 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
getData(); getData();
}, [surveyId]); }, [surveyId]);
useEffect(() => {
setPreviewFrameHeight(560);
}, [emailPreviewDocument]);
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
const { contentDocument } = event.currentTarget;
if (!contentDocument) {
return;
}
const nextHeight = Math.max(
contentDocument.body.scrollHeight,
contentDocument.documentElement.scrollHeight,
560
);
setPreviewFrameHeight(nextHeight);
};
const sendPreviewEmail = async () => { const sendPreviewEmail = async () => {
try { try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId }); const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
@@ -73,7 +127,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
if (activeTab === "preview") { if (activeTab === "preview") {
return ( return (
<div className="space-y-4 pb-4"> <div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"> <div
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="mb-6 flex gap-2"> <div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" /> <div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" /> <div className="h-3 w-3 rounded-full bg-amber-500" />
@@ -87,9 +143,17 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
{t("environments.surveys.share.send_email.email_subject_label")} :{" "} {t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")} {t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div> </div>
<div className="p-2"> <div data-testid="survey-email-preview-content">
{emailHtml ? ( {emailPreviewDocument ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} /> <iframe
className="mt-2 w-full rounded-md border-0 bg-white"
data-testid="survey-email-preview-frame"
onLoad={handlePreviewFrameLoad}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={emailPreviewDocument}
style={{ height: `${previewFrameHeight}px` }}
title={t("environments.surveys.share.send_email.email_preview_tab")}
/>
) : ( ) : (
<LoadingSpinner /> <LoadingSpinner />
)} )}
@@ -16,13 +16,19 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false); const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;"> const separator = surveyUrl.includes("?") ? "&" : "?";
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}" 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;"> frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe> </iframe>
</div>`; </div>`;
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
return ( return (
<> <>
<CodeBlock language="html" noMargin> <CodeBlock language="html" noMargin>
@@ -48,6 +54,15 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
{t("common.copy_code")} {t("common.copy_code")}
<CopyIcon /> <CopyIcon />
</Button> </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>
</> </>
); );
}; };
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -5,6 +5,7 @@ import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling"; import { getStyling } from "@/lib/utils/styling";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template"; import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => { export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const t = await getTranslate(); const t = await getTranslate();
@@ -20,9 +21,6 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const styling = getStyling(project, survey); const styling = getStyling(project, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id; const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t); const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned; return extractEmailBodyFragment(html.toString());
}; };
@@ -0,0 +1,11 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
@@ -191,6 +191,61 @@ describe("getSurveySummaryMeta", () => {
expect(meta.dropOffPercentage).toBe(0); expect(meta.dropOffPercentage).toBe(0);
expect(meta.ttcAverage).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", () => { describe("getSurveySummaryDropOff", () => {
@@ -1094,7 +1094,9 @@ export const getResponsesForSummary = reactCache(
const transformedResponses: TSurveySummaryResponse[] = await Promise.all( const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => { responses.map((responsePrisma) => {
return { return {
...responsePrisma, id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact contact: responsePrisma.contact
? { ? {
id: responsePrisma.contact.id as string, id: responsePrisma.contact.id as string,
@@ -1103,6 +1105,10 @@ export const getResponsesForSummary = reactCache(
)?.value as string, )?.value as string,
} }
: null, : 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} pageTitle={survey.name}
cta={ cta={
<SurveyAnalysisCTA <SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
user={user} user={user}
publicDomain={publicDomain} publicDomain={publicDomain}
@@ -78,7 +76,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isStorageConfigured={IS_STORAGE_CONFIGURED} isStorageConfigured={IS_STORAGE_CONFIGURED}
/> />
}> }>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" /> <SurveyAnalysisNavigation activeId="summary" />
</PageHeader> </PageHeader>
<SummaryPage <SummaryPage
environment={environment} environment={environment}
@@ -3,8 +3,9 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types"; 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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions"; import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { import {
@@ -16,17 +17,9 @@ import {
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
interface SurveyStatusDropdownProps { export const SurveyStatusDropdown = () => {
environment: TEnvironment; const { environment } = useEnvironment();
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void; const { survey } = useSurvey();
survey: TSurvey;
}
export const SurveyStatusDropdown = ({
environment,
updateLocalSurveyStatus,
survey,
}: SurveyStatusDropdownProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -46,10 +39,6 @@ export const SurveyStatusDropdown = ({
toast.success(toastMessage); toast.success(toastMessage);
} }
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh(); router.refresh();
} else { } else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse); 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>;
};
@@ -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",
})
);
});
});
@@ -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"; 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 handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined; const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -117,6 +123,10 @@ const handler = async (req: Request, ctx: any) => {
events: { events: {
...baseAuthOptions.events, ...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) { async signIn({ user, account, isNewUser }: any) {
if (isSsoRecoveryVerificationFlow(account, user)) {
return;
}
try { try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser }); await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) { } 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);
}
}
};
+57 -1
View File
@@ -1,9 +1,15 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth"; import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest } from "./auth"; import { authenticateRequest, handleErrorResponse } from "./auth";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({ vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(), getApiKeyWithPermissions: vi.fn(),
@@ -193,3 +199,53 @@ describe("authenticateRequest", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("handleErrorResponse", () => {
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
const response = handleErrorResponse(new Error("NotAuthenticated"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("not_authenticated");
});
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
const response = handleErrorResponse(new Error("Unauthorized"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("unauthorized");
});
test("returns 409 conflict for UniqueConstraintError", async () => {
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
expect(response.status).toBe(409);
const body = await response.json();
expect(body.code).toBe("conflict");
expect(body.message).toBe("Action with name foo already exists");
});
test("returns 400 badRequest for DatabaseError", async () => {
const response = handleErrorResponse(new DatabaseError("db boom"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("db boom");
});
test("returns 400 badRequest for InvalidInputError", async () => {
const response = handleErrorResponse(new InvalidInputError("bad input"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
const response = handleErrorResponse(new Error("something else"));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Some error occurred");
});
});
+9 -1
View File
@@ -1,6 +1,11 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
@@ -40,6 +45,9 @@ export const handleErrorResponse = (error: any): Response => {
case "Unauthorized": case "Unauthorized":
return responses.unauthorizedResponse(); return responses.unauthorizedResponse();
default: default:
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if ( if (
error instanceof DatabaseError || error instanceof DatabaseError ||
error instanceof InvalidInputError || error instanceof InvalidInputError ||
@@ -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,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);
});
});
@@ -70,6 +70,7 @@ const mockEnvironmentData = {
displayOption: "displayOnce", displayOption: "displayOnce",
hiddenFields: { enabled: false }, hiddenFields: { enabled: false },
isBackButtonHidden: false, isBackButtonHidden: false,
isAutoProgressingEnabled: true,
triggers: [], triggers: [],
displayPercentage: null, displayPercentage: null,
delay: 0, delay: 0,
@@ -122,6 +123,13 @@ describe("getEnvironmentStateData", () => {
surveys: expect.any(Object), 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 () => { test("should throw ResourceNotFoundError when environment is not found", async () => {
@@ -121,6 +121,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
displayOption: true, displayOption: true,
hiddenFields: true, hiddenFields: true,
isBackButtonHidden: true, isBackButtonHidden: true,
isAutoProgressingEnabled: true,
triggers: { triggers: {
select: { select: {
actionClass: { actionClass: {
@@ -10,6 +10,8 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -123,17 +125,118 @@ export const POST = withV1ApiWrapper({
} }
if (survey.environmentId !== environmentId) { if (survey.environmentId !== environmentId) {
return { return {
response: responses.badRequestResponse( response: responses.badRequestResponse("Survey does not belong to this environment", undefined, true),
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
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)) { if (!validateFileUploads(responseInputData.data, survey.questions)) {
return { return {
response: responses.badRequestResponse("Invalid file upload response"), response: responses.badRequestResponse("Invalid file upload response"),
@@ -75,11 +75,7 @@ export const POST = withV1ApiWrapper({
if (survey.environmentId !== environmentId) { if (survey.environmentId !== environmentId) {
return { return {
response: responses.badRequestResponse( response: responses.badRequestResponse("Survey does not belong to this environment", undefined, true),
"Survey does not belong to the environment",
{ surveyId, environmentId },
true
),
}; };
} }
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -80,6 +80,11 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(actionClass), response: responses.successResponse(actionClass),
}; };
} catch (error) { } catch (error) {
if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message),
};
}
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
return { return {
response: responses.badRequestResponse(error.message), response: responses.badRequestResponse(error.message),
@@ -1,178 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { publicUserSelect } from "@/lib/user/public-user";
import { GET } from "./route";
const mocks = vi.hoisted(() => ({
headers: vi.fn(),
getSessionUser: vi.fn(),
parseApiKeyV2: vi.fn(),
hashSha256: vi.fn(),
verifySecret: vi.fn(),
applyRateLimit: vi.fn(),
notAuthenticatedResponse: vi.fn(
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
),
tooManyRequestsResponse: vi.fn(
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
),
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
}));
vi.mock("next/headers", () => ({
headers: mocks.headers,
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
apiKey: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
badRequestResponse: mocks.badRequestResponse,
},
}));
vi.mock("@/lib/crypto", () => ({
hashSha256: mocks.hashSha256,
parseApiKeyV2: mocks.parseApiKeyV2,
verifySecret: mocks.verifySecret,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v1: { windowMs: 60_000, max: 1000 },
},
},
}));
const getMockHeaders = (apiKey: string | null) => ({
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
});
describe("v1 management me route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.headers.mockResolvedValue(getMockHeaders(null));
mocks.getSessionUser.mockResolvedValue(undefined);
mocks.parseApiKeyV2.mockReturnValue(null);
mocks.hashSha256.mockReturnValue("hashed-api-key");
mocks.verifySecret.mockResolvedValue(false);
mocks.applyRateLimit.mockResolvedValue(undefined);
});
test("returns a sanitized authenticated user for session-based requests", async () => {
const publicUser = {
id: "user_123",
name: "Test User",
email: "test@example.com",
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
createdAt: new Date("2025-04-17T20:09:14.021Z"),
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
twoFactorEnabled: false,
identityProvider: "email" as const,
notificationSettings: {
alert: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US" as const,
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
isActive: true,
};
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: publicUser.id },
select: publicUserSelect,
});
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
});
test("returns the existing unauthenticated response when no session is present", async () => {
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody).toEqual({ message: "Not authenticated" });
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("preserves the API key response path", async () => {
const apiKeyData = {
id: "api_key_123",
organizationId: "org_123",
hashedKey: "stored-hash",
lastUsedAt: new Date(),
apiKeyEnvironments: [
{
permission: "manage",
environment: {
id: "env_123",
type: "development",
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
projectId: "project_123",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
},
},
],
};
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual({
id: "env_123",
type: "development",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
});
expect(mocks.getSessionUser).not.toHaveBeenCalled();
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
});
});
@@ -96,14 +96,7 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
} }
if (survey.environmentId !== environmentId) { if (survey.environmentId !== environmentId) {
return { return {
error: responses.badRequestResponse( error: responses.badRequestResponse("Survey does not belong to this environment", undefined, true),
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
),
}; };
} }
return { survey }; return { survey };
@@ -1,47 +1,19 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 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"; import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({ const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
validateInputs: vi.fn(), mockDeleteSharedSurvey: vi.fn(),
})); }));
vi.mock("@formbricks/database", () => ({
prisma: { vi.mock("@/modules/survey/lib/surveys", () => ({
survey: { deleteSurvey: mockDeleteSharedSurvey,
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
})); }));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2"; 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 = { const mockDeletedSurveyLink = {
id: surveyId, id: surveyId,
environmentId, environmentId: "clq5n7p1q0000m7z0h5p6g3r3",
type: "link", type: "link",
segment: null, segment: null,
triggers: [], triggers: [],
@@ -56,66 +28,20 @@ describe("deleteSurvey", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
test("should delete a link survey without a segment and revalidate caches", async () => { test("delegates survey deletion to the shared service", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any); mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
const deletedSurvey = await deleteSurvey(surveyId); const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]); expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(deletedSurvey).toEqual(mockDeletedSurveyLink); expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
}); });
test("should handle PrismaClientKnownRequestError during survey deletion", async () => { test("rethrows shared delete service errors", 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 () => {
const genericError = new Error("Something went wrong"); const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError); mockDeleteSharedSurvey.mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError); await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled(); expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
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();
}); });
}); });
@@ -1,43 +1,3 @@
import { Prisma } from "@prisma/client"; import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
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";
export const deleteSurvey = async (surveyId: string) => { export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
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;
}
};
@@ -1,5 +1,6 @@
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types"; import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth"; import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys"; 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)), response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
}; };
} catch (error) { } catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return { return {
response: handleErrorResponse(error), response: handleErrorResponse(error),
}; };
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; 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 { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
@@ -175,10 +175,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError); ).rejects.toThrow(ResourceNotFoundError);
}); });
test("should throw DatabaseError on Prisma known request error", async () => { test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002", code: "P2002",
clientVersion: "test", 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); vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect( await expect(
@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; 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 { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
@@ -129,6 +129,13 @@ export const createResponse = async (
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { 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); throw new DatabaseError(error.message);
} }
@@ -124,11 +124,8 @@ describe("checkSurveyValidity", () => {
expect(result).toBeInstanceOf(Response); expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400); expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith( expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another environment", "Survey does not belong to this environment",
{ undefined,
"survey.environmentId": "env-2",
environmentId: "env-1",
},
true true
); );
}); });
@@ -17,14 +17,7 @@ export const checkSurveyValidity = async (
responseInput: TResponseInputV2 responseInput: TResponseInputV2
): Promise<Response | null> => { ): Promise<Response | null> => {
if (survey.environmentId !== environmentId) { if (survey.environmentId !== environmentId) {
return responses.badRequestResponse( return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
} }
if (survey.type === "link" && survey.singleUse?.enabled) { if (survey.type === "link" && survey.singleUse?.enabled) {
@@ -1,6 +1,6 @@
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { ZEnvironmentId } from "@formbricks/types/environment"; 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 { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils"; import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter"; import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -177,6 +177,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true); return responses.badRequestResponse(error.message, undefined, true);
} }
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse(); const response = getUnexpectedPublicErrorResponse();
reportApiError({ reportApiError({
request, request,
+132
View File
@@ -9,6 +9,22 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: vi.fn(), 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", () => ({ vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession, getServerSession: mockGetServerSession,
})); }));
@@ -25,6 +41,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined), 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", () => ({ vi.mock("@formbricks/logger", () => ({
logger: { logger: {
withContext: vi.fn(() => ({ withContext: vi.fn(() => ({
@@ -45,6 +69,114 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks(); 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 () => { 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"); const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({ mockGetServerSession.mockResolvedValue({
+76 -2
View File
@@ -4,10 +4,13 @@ import { z } from "zod";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors"; import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit"; 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 { import {
type InvalidParam, type InvalidParam,
problemBadRequest, problemBadRequest,
@@ -15,7 +18,7 @@ import {
problemTooManyRequests, problemTooManyRequests,
problemUnauthorized, problemUnauthorized,
} from "./response"; } from "./response";
import type { TV3Authentication } from "./types"; import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny; type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>; type MaybePromise<T> = T | Promise<T>;
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest; req: NextRequest;
props: TProps; props: TProps;
authentication: TV3Authentication; authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput; parsedInput: TParsedInput;
requestId: string; requestId: string;
instance: string; instance: string;
@@ -48,6 +52,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S; schemas?: S;
rateLimit?: boolean; rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig; customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>; handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
}; };
@@ -293,10 +299,61 @@ async function applyV3RateLimitOrRespond(params: {
return null; 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>( export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps> params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => { ): ((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> => { return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID(); 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, method: req.method,
path: instance, path: instance,
}); });
let auditLog: TV3AuditLog | undefined;
try { try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance); const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -331,17 +389,33 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse; return rateLimitResponse;
} }
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({ const response = await handler({
req, req,
props, props,
authentication: authResult.authentication, authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput, parsedInput: parsedInputResult.parsedInput,
requestId, requestId,
instance, instance,
}); });
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId); return ensureRequestIdHeader(response, requestId);
} catch (error) { } catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error"); log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance); return problemInternalError(requestId, "An unexpected error occurred.", instance);
} }
+25
View File
@@ -7,6 +7,7 @@ import {
problemTooManyRequests, problemTooManyRequests,
problemUnauthorized, problemUnauthorized,
successListResponse, successListResponse,
successResponse,
} from "./response"; } from "./response";
describe("v3 problem responses", () => { describe("v3 problem responses", () => {
@@ -93,3 +94,27 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0"); 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 }); 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 { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/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 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);
}
},
});
@@ -34,7 +34,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({ expect(r.invalid_params[0]).toEqual({
name: "foo", name: "foo",
reason: reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.", "Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
}); });
}); });
@@ -45,7 +45,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({ expect(r.invalid_params[0]).toEqual({
name: "after", name: "after",
reason: reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.", "Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
}); });
} }
}); });
@@ -57,7 +57,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({ expect(r.invalid_params[0]).toEqual({
name: "name", name: "name",
reason: reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.", "Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
}); });
} }
}); });
@@ -68,11 +68,20 @@ describe("parseV3SurveysListQuery", () => {
if (r.ok) { if (r.ok) {
expect(r.limit).toBe(20); expect(r.limit).toBe(20);
expect(r.cursor).toBeNull(); expect(r.cursor).toBeNull();
expect(r.includeTotalCount).toBe(true);
expect(r.sortBy).toBe("updatedAt"); expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined(); expect(r.filterCriteria).toBeUndefined();
} }
}); });
test("parses includeTotalCount=false", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&includeTotalCount=false`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.includeTotalCount).toBe(false);
}
});
test("builds filter from explicit operator params", () => { test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery( const r = parseV3SurveysListQuery(
params( params(
@@ -102,7 +111,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({ expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]", name: "filter[createdBy][in]",
reason: reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.", "Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
}); });
} }
}); });
@@ -28,6 +28,7 @@ const SUPPORTED_QUERY_PARAMS = [
"workspaceId", "workspaceId",
"limit", "limit",
"cursor", "cursor",
"includeTotalCount",
FILTER_NAME_CONTAINS_QUERY_PARAM, FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM, FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM, FILTER_TYPE_IN_QUERY_PARAM,
@@ -53,6 +54,11 @@ const ZV3SurveysListQuery = z.object({
workspaceId: ZId, workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT), limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(), cursor: z.string().min(1).optional(),
includeTotalCount: z
.enum(["true", "false"])
.optional()
.transform((value) => value !== "false")
.default(true),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z [FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string() .string()
.max(512) .max(512)
@@ -71,6 +77,7 @@ export type TV3SurveysListQueryParseResult =
workspaceId: string; workspaceId: string;
limit: number; limit: number;
cursor: TSurveyListPageCursor | null; cursor: TSurveyListPageCursor | null;
includeTotalCount: boolean;
sortBy: TSurveyListSort; sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined; filterCriteria: TSurveyFilterCriteria | undefined;
} }
@@ -111,6 +118,7 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
workspaceId: searchParams.get("workspaceId"), workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined, limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined, cursor: searchParams.get("cursor")?.trim() || undefined,
includeTotalCount: searchParams.get("includeTotalCount")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined, [FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined, [FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined, [FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
@@ -153,6 +161,7 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
workspaceId: q.workspaceId, workspaceId: q.workspaceId,
limit: q.limit, limit: q.limit,
cursor, cursor,
includeTotalCount: q.includeTotalCount,
sortBy, sortBy,
filterCriteria: buildFilterCriteria(q), filterCriteria: buildFilterCriteria(q),
}; };
+25 -2
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey"; import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page"; import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route"; import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({ const { mockAuthenticateRequest } = vi.hoisted(() => ({
@@ -257,6 +257,29 @@ describe("GET /api/v3/surveys", () => {
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined); expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
}); });
test("skips totalCount when includeTotalCount=false", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: null,
});
const cursor = encodeSurveyListPageCursor({
version: 1,
sortBy: "updatedAt",
value: "2026-04-15T10:00:00.000Z",
id: "survey_1",
});
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&cursor=${cursor}&includeTotalCount=false`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 20, nextCursor: null, totalCount: null });
expect(getSurveyCount).not.toHaveBeenCalled();
});
test("passes filter query to getSurveyListPage", async () => { test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] }; const filterCriteria = { status: ["inProgress"] };
const req = createRequest( const req = createRequest(
@@ -321,11 +344,11 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any); const res = await GET(req, {} as any);
const body = await res.json(); const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks"); 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("_count");
expect(body.data[0]).not.toHaveProperty("environmentId"); expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1"); expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1"); expect(body.data[0].workspaceId).toBe("env_1");
expect(body.data[0].singleUse).toBeNull();
}); });
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => { test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
+12 -11
View File
@@ -46,21 +46,22 @@ export const GET = withV3ApiWrapper({
const { environmentId } = authResult; const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([ const surveyPagePromise = getSurveyListPage(environmentId, {
getSurveyListPage(environmentId, { limit: parsed.limit,
limit: parsed.limit, cursor: parsed.cursor,
cursor: parsed.cursor, sortBy: parsed.sortBy,
sortBy: parsed.sortBy, filterCriteria: parsed.filterCriteria,
filterCriteria: parsed.filterCriteria, });
}), const totalCountPromise = parsed.includeTotalCount
getSurveyCount(environmentId, parsed.filterCriteria), ? getSurveyCount(environmentId, parsed.filterCriteria)
]); : Promise.resolve(null);
const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]);
return successListResponse( return successListResponse(
surveys.map(serializeV3SurveyListItem), surveyPage.surveys.map(serializeV3SurveyListItem),
{ {
limit: parsed.limit, limit: parsed.limit,
nextCursor, nextCursor: surveyPage.nextCursor,
totalCount, totalCount,
}, },
{ requestId, cache: "private, no-store" } { requestId, cache: "private, no-store" }
+2 -2
View File
@@ -1,6 +1,6 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys"; import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & { export type TV3SurveyListItem = Omit<TSurvey, "environmentId"> & {
workspaceId: string; workspaceId: string;
}; };
@@ -9,7 +9,7 @@ export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId. * Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/ */
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem { export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey; const { environmentId, ...rest } = survey;
return { return {
...rest, ...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", () => { describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => { test("should return a too many requests response", () => {
const message = "Rate limit exceeded"; const message = "Rate limit exceeded";
+27 -1
View File
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed" | "method_not_allowed"
| "not_authenticated" | "not_authenticated"
| "forbidden" | "forbidden"
| "too_many_requests"; | "too_many_requests"
| "conflict";
message: string; message: string;
details: { details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[]; [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 = ( const tooManyRequestsResponse = (
message: string, message: string,
cors: boolean = false, cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse, successResponse,
tooManyRequestsResponse, tooManyRequestsResponse,
forbiddenResponse, forbiddenResponse,
conflictResponse,
}; };
+8 -7
View File
@@ -971,13 +971,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
elements: [ elements: [
buildOpenTextElement({ buildOpenTextElement({
id: reusableElementIds[2], id: reusableElementIds[2],
headline: t("templates.improve_trial_conversion_question_2_headline"), headline: t("templates.improve_trial_conversion_question_3_headline"),
required: true, required: true,
inputType: "text", inputType: "text",
}), }),
], ],
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")], 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, t,
}), }),
buildBlock({ buildBlock({
@@ -1647,14 +1647,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [ elements: [
buildMultipleChoiceElement({ buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle, type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: "What's your primary goal for using $[projectName]?", headline: t("templates.identify_customer_goals_question_1_headline"),
required: true, required: true,
shuffleOption: "none", shuffleOption: "none",
choices: [ choices: [
"Understand my user base deeply", t("templates.identify_customer_goals_question_1_choice_1"),
"Identify upselling opportunities", t("templates.identify_customer_goals_question_1_choice_2"),
"Build the best possible product", t("templates.identify_customer_goals_question_1_choice_3"),
"Rule the world to make everyone breakfast brussels sprouts.", t("templates.identify_customer_goals_question_1_choice_4"),
], ],
}), }),
], ],
@@ -4926,6 +4926,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
showLanguageSwitch: false, showLanguageSwitch: false,
followUps: [], followUps: [],
isBackButtonHidden: false, isBackButtonHidden: false,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: false, isCaptureIpEnabled: false,
metadata: {}, metadata: {},
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime) questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
+1
View File
@@ -19,6 +19,7 @@
"ro-RO", "ro-RO",
"ru-RU", "ru-RU",
"sv-SE", "sv-SE",
"tr-TR",
"zh-Hans-CN", "zh-Hans-CN",
"zh-Hant-TW" "zh-Hant-TW"
] ]
+43 -30
View File
@@ -125,6 +125,7 @@ checksums:
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507 common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
common/choice_n: ee41eb382bae7289a221d959f3046965
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603 common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -138,6 +139,7 @@ checksums:
common/close: 2c2e22f8424a1031de89063bd0022e16 common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2 common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21 common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/column_n: 550955aee6a92d8ccc96989300add693
common/completed: 0e4bbce9985f25eb673d9a054c8d5334 common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configuration: 923ec0502721489202f6222dd4107163 common/configuration: 923ec0502721489202f6222dd4107163
common/confirm: 90930b51154032f119fa75c1bd422d8b common/confirm: 90930b51154032f119fa75c1bd422d8b
@@ -168,6 +170,7 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4 common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60 common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/data_refreshed_successfully: 85728c61a9e1a16e46af69ddf0dbcda6
common/date: 56f41c5d30a76295bb087b20b7bee4c3 common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48 common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4 common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -209,6 +212,7 @@ checksums:
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784 common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4 common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/field_placeholder: ec26d96643d86da164162204ec6c650f
common/filter: 626325a05e4c8800f7ede7012b0cadaf common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b common/first_name: cf040a5d6a9fd696be400380cc99f54b
@@ -220,10 +224,12 @@ checksums:
common/generate: 0345bf322c191e70d01fd6607ec5c2f8 common/generate: 0345bf322c191e70d01fd6607ec5c2f8
common/go_back: b917ea82facb90c88c523b255d29f84b common/go_back: b917ea82facb90c88c523b255d29f84b
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2 common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
common/headline: 0023cbe059bbadcc77312825cbbce5ac
common/hidden: fa290c6ada5869d744ed35e9cca64699 common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972 common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1 common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/html: f750870203043349d570d8f5865ca0f8
common/id: c8886d38aeea2ed5f785aba4fc96784b common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830 common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f common/images: 9305827c28694866f49db42b4c51831f
@@ -271,7 +277,6 @@ checksums:
common/months: da74749fbe80394fa0f72973d7b0964a common/months: da74749fbe80394fa0f72973d7b0964a
common/move_down: 4f4de55743043355ad4a839aff2c48ff common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
common/my_product: ad022177062f9ef6e9acf33b13e889aa common/my_product: ad022177062f9ef6e9acf33b13e889aa
common/name: 9368b5a047572b6051f334af5aa76819 common/name: 9368b5a047572b6051f334af5aa76819
common/new: 126d036fae5fb6b629728ecb97e6195b common/new: 126d036fae5fb6b629728ecb97e6195b
@@ -286,6 +291,7 @@ checksums:
common/no_result_found: fedddbc0149972ea072a9e063198a16d common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280 common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
common/no_surveys_found: 7b74706fe4f4aacd7d858e19e444fe85 common/no_surveys_found: 7b74706fe4f4aacd7d858e19e444fe85
common/no_text_found: 27350f35bdd57b3701c7ec578a1a0e11
common/none_of_the_above: e007f0b1e046d5ddbbcfbd87940456ee common/none_of_the_above: e007f0b1e046d5ddbbcfbd87940456ee
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4 common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
@@ -309,7 +315,7 @@ checksums:
common/organization_settings: 11528aa89ae9935e55dcb54478058775 common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2 common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8 common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/overlay_color: 4b72073285d13fff93d094aabffe05ac common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5 common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48 common/password: 223a61cf906ab9c40d22612c588dff48
@@ -327,7 +333,6 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960 common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08 common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
@@ -342,6 +347,7 @@ checksums:
common/quotas_description: a2caa44fa74664b3b6007e813f31a754 common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/refresh: c0aec3f31be4c984bae9a482572d2857
common/remove: dba2fe5fe9f83f8078c687f28cba4b52 common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6 common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069 common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
@@ -354,6 +360,7 @@ checksums:
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28 common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f common/role: 53743bbb6ca938f5b893552e839d067f
common/row_n: eb5bb04b244fadd7a6962aa58bf6bd17
common/saas: f01686245bcfb35a3590ab56db677bdb common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647 common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454 common/save: f7a2929f33bc420195e59ac5a8bcd454
@@ -392,6 +399,7 @@ checksums:
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6 common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
common/string: 4ddccc1974775ed7357f9beaf9361cec common/string: 4ddccc1974775ed7357f9beaf9361cec
common/styling: 240fc91eb03c52d46b137f82e7aec2a1 common/styling: 240fc91eb03c52d46b137f82e7aec2a1
common/subheader: 73a37d57cb9807e574a42bd0c7e334ed
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
common/summary: 13eb7b8a239fb4702dfdaee69100a220 common/summary: 13eb7b8a239fb4702dfdaee69100a220
common/survey: b659d270a53dada994d926e0cc6e9a54 common/survey: b659d270a53dada994d926e0cc6e9a54
@@ -460,7 +468,6 @@ checksums:
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1 common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2 common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302 common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45 common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
@@ -633,8 +640,6 @@ checksums:
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26 environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
@@ -1132,7 +1137,7 @@ checksums:
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04 environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
environments/settings/general/organization_name_placeholder: fc91de3ddc89ab77f30d555778312380 environments/settings/general/organization_name_placeholder: abcee7d91a848e573b63a763cfaf6a08
environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432 environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432
environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6 environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6
environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813 environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813
@@ -1187,6 +1192,7 @@ checksums:
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606 environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6 environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52 environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
@@ -1234,15 +1240,9 @@ checksums:
environments/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150 environments/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
environments/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399 environments/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
environments/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee 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_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/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
environments/surveys/edit/1_choose_the_default_language_for_this_survey: d22759857c1bb3d6b337e8e9d501dad7 environments/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
environments/surveys/edit/2_activate_translation_for_specific_languages: 9f23cb81ad301073df45ae36f0d94f9e
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66 environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
@@ -1291,6 +1291,8 @@ checksums:
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516 environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0 environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012 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: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681 environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963 environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -1336,6 +1338,7 @@ checksums:
environments/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918 environments/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
environments/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab environments/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
environments/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b environments/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
environments/surveys/edit/change_default: 6236a6c8a28489ba7c4cad7426806859
environments/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa environments/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
environments/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93 environments/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93
environments/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836 environments/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836
@@ -1348,6 +1351,7 @@ checksums:
environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117 environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2 environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
environments/surveys/edit/code: 343bc5386149b97cece2b093c39034b2
environments/surveys/edit/color: 9d53d1d120e8b8954bcae9a322573748 environments/surveys/edit/color: 9d53d1d120e8b8954bcae9a322573748
environments/surveys/edit/column_used_in_logic_error: deffbd3e8f4bd71a5e522682e8ee60dd environments/surveys/edit/column_used_in_logic_error: deffbd3e8f4bd71a5e522682e8ee60dd
environments/surveys/edit/columns: 14896556dc1535d70198854757f704ec environments/surveys/edit/columns: 14896556dc1535d70198854757f704ec
@@ -1372,6 +1376,7 @@ checksums:
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2 environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04 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_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1 environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
@@ -1391,7 +1396,6 @@ checksums:
environments/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b environments/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54 environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318 environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1 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_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 environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
@@ -1478,7 +1482,7 @@ checksums:
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62 environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768 environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489 environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859 environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830 environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
@@ -1527,11 +1531,13 @@ checksums:
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53 environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111 environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160 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_all_fields: 187240509163b2f52a400a565e57c67f
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151 environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7 environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e 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/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
@@ -1539,7 +1545,7 @@ checksums:
environments/surveys/edit/next_button_label: 39f1e82ae1dea5e400e8ed7c98c6ad9c environments/surveys/edit/next_button_label: 39f1e82ae1dea5e400e8ed7c98c6ad9c
environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516 environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
environments/surveys/edit/no_images_found_for: 7dabcbcc7084f59c6ec0971895dfcd29 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_option_found: a1a3aa7e6c13b6bb8df20a1a104c7c04
environments/surveys/edit/no_recall_items_found: 729e2b02e412cdc79f5ad94b1918620c environments/surveys/edit/no_recall_items_found: 729e2b02e412cdc79f5ad94b1918620c
environments/surveys/edit/no_variables_yet_add_first_one_below: c8704b9ebc9c26c0e9dd50c099ba88cd environments/surveys/edit/no_variables_yet_add_first_one_below: c8704b9ebc9c26c0e9dd50c099ba88cd
@@ -1566,6 +1572,7 @@ checksums:
environments/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4 environments/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
environments/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a environments/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366 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: afc502baa2da81d9c9618da1c3b5a57a
environments/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5 environments/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
environments/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa environments/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
@@ -1655,6 +1662,7 @@ checksums:
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42 environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79 environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
environments/surveys/edit/show_in_order: 15784a59572eb8a6dba6b918c31a9493
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413 environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60 environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
@@ -1686,7 +1694,6 @@ checksums:
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739 environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579 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/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073 environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63 environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
@@ -1694,9 +1701,11 @@ checksums:
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: e45beba7ae126775f4966776c982a3b4 environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: e45beba7ae126775f4966776c982a3b4
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6 environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987 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/three_points: d7f299aec752d7d690ef0ab6373327ae
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67 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/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
environments/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c environments/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
environments/surveys/edit/type_field_id: 714b845806236bb8a9d6a09933b836e9 environments/surveys/edit/type_field_id: 714b845806236bb8a9d6a09933b836e9
@@ -1769,6 +1778,7 @@ checksums:
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772 environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84 environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219 environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
environments/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708 environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919 environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
@@ -1951,7 +1961,6 @@ checksums:
environments/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac environments/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
environments/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109 environments/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
environments/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452 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_added_successfully: e247f65020cd87454bcec0da6f0fd034
environments/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b environments/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -2036,7 +2045,6 @@ checksums:
environments/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00 environments/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
environments/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5 environments/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
environments/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46 environments/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
environments/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
environments/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87 environments/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
environments/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41 environments/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
environments/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be environments/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
@@ -2113,7 +2121,6 @@ checksums:
environments/workspace/languages/duplicate_language_or_language_id: 0e17e3794b24e2428ca6ffadae0d08f3 environments/workspace/languages/duplicate_language_or_language_id: 0e17e3794b24e2428ca6ffadae0d08f3
environments/workspace/languages/edit_languages: c9d36f6b28557cc7d54e87c37dc18fdd environments/workspace/languages/edit_languages: c9d36f6b28557cc7d54e87c37dc18fdd
environments/workspace/languages/identifier: 7d8ade6b85e96216bcd73adeeeeecd8c environments/workspace/languages/identifier: 7d8ade6b85e96216bcd73adeeeeecd8c
environments/workspace/languages/incomplete_translations: d82908b5725f18f5849c7876ad497ebc
environments/workspace/languages/language: 277fd1a41cc237a437cd1d5e4a80463b environments/workspace/languages/language: 277fd1a41cc237a437cd1d5e4a80463b
environments/workspace/languages/language_deleted_successfully: 4a805d030491f3fe608d2371b0cfcd83 environments/workspace/languages/language_deleted_successfully: 4a805d030491f3fe608d2371b0cfcd83
environments/workspace/languages/languages_updated_successfully: 60de474c99c5059c0458cddd0b016c15 environments/workspace/languages/languages_updated_successfully: 60de474c99c5059c0458cddd0b016c15
@@ -2124,7 +2131,6 @@ checksums:
environments/workspace/languages/remove_language: 1a64563b0f37109f97b78eddd493e381 environments/workspace/languages/remove_language: 1a64563b0f37109f97b78eddd493e381
environments/workspace/languages/remove_language_from_surveys_to_remove_it_from_workspace: 61bc96f9db31a29a649cc9ecd684bc39 environments/workspace/languages/remove_language_from_surveys_to_remove_it_from_workspace: 61bc96f9db31a29a649cc9ecd684bc39
environments/workspace/languages/search_items: b54b751c8b075200be579d6c8e58096b environments/workspace/languages/search_items: b54b751c8b075200be579d6c8e58096b
environments/workspace/languages/translate: 59f9803b27e2030ba7323ed239116cf7
environments/workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9 environments/workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9
environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
environments/workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451 environments/workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
@@ -2182,12 +2188,12 @@ checksums:
environments/workspace/look/advanced_styling_field_track_bg_description: 8a56258273dfe49e83fe752ea9e8daed 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: 9ce57cb4583039c224a37e013efb6b8f
environments/workspace/look/advanced_styling_field_track_height_description: 90243a4374e15d9118ad0fd93d5f3614 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: 2767a5db32742073a01aac16488e93dc
environments/workspace/look/advanced_styling_field_upper_label_color_description: ae2276506807c7ceeb7a8b87723a8dd4 environments/workspace/look/advanced_styling_field_upper_label_color_description: 58f43ce21b7f6539cc937aa80c7e8060
environments/workspace/look/advanced_styling_field_upper_label_size: ea0ca9a3ffa1650f97a31df453b0afc7 environments/workspace/look/advanced_styling_field_upper_label_size: 3342babd1df61a3bdf7a3284137f7c24
environments/workspace/look/advanced_styling_field_upper_label_size_description: 061668625be0f7a68ebc2e2ebe49e5a9 environments/workspace/look/advanced_styling_field_upper_label_size_description: 867a89a79ed7ac7f1c6b0f3481a67f26
environments/workspace/look/advanced_styling_field_upper_label_weight: 946c5836d2cfaaee21e494424d550887 environments/workspace/look/advanced_styling_field_upper_label_weight: a9a0de9e840518d282cfdbcb02d059b5
environments/workspace/look/advanced_styling_field_upper_label_weight_description: 916b03c719a8dead351679336aabcf53 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_buttons: 3b44d6e2800e7bf3f133f1bce435f4c2
environments/workspace/look/advanced_styling_section_headlines: 6def704c0ac2ecb5951400c806856a41 environments/workspace/look/advanced_styling_section_headlines: 6def704c0ac2ecb5951400c806856a41
environments/workspace/look/advanced_styling_section_inputs: 76bbeb561122a72fd3ec8c49eff7c563 environments/workspace/look/advanced_styling_section_inputs: 76bbeb561122a72fd3ec8c49eff7c563
@@ -2720,6 +2726,11 @@ checksums:
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854 templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8 templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595 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_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84 templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
@@ -2794,12 +2805,14 @@ checksums:
templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9 templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_2_headline: 05dd4820f60b9d267a9affc7e662f029 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_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45 templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca
templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9 templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_5_headline: dbd99e216fcbf8693b8e77fbd77e1c84 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_headline: f15239ecc4f1a6bd8bea77a38b39c844
templates/improve_trial_conversion_question_6_subheader: e147ddbb609fff6e6fc78fb1f4add0ac templates/improve_trial_conversion_question_6_subheader: e147ddbb609fff6e6fc78fb1f4add0ac
templates/integration_setup_survey_description: 696ccab07d7098cdb79c224fa1208889 templates/integration_setup_survey_description: 696ccab07d7098cdb79c224fa1208889
+151 -2
View File
@@ -1,12 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes"; import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { import {
createActionClass,
deleteActionClass, deleteActionClass,
getActionClass, getActionClass,
getActionClassByEnvironmentIdAndName, getActionClassByEnvironmentIdAndName,
getActionClasses, getActionClasses,
updateActionClass,
} from "./service"; } from "./service";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
@@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({
findFirst: vi.fn(), findFirst: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}, },
}, },
})); }));
@@ -178,4 +184,147 @@ describe("ActionClass Service", () => {
await expect(deleteActionClass("id4")).rejects.toThrow("unknown"); await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
}); });
}); });
describe("createActionClass", () => {
const codeInput: TActionClassInput = {
name: "Code Action",
description: "desc",
type: "code",
key: "code-action-key",
environmentId: "env-create",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should create and return the action class", async () => {
const created: TActionClass = {
id: "id-create",
createdAt: new Date(),
updatedAt: new Date(),
name: codeInput.name,
description: codeInput.description ?? null,
type: "code",
key: codeInput.type === "code" ? codeInput.key : null,
noCodeConfig: null,
environmentId: codeInput.environmentId,
};
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
const result = await createActionClass(codeInput.environmentId, codeInput);
expect(result).toEqual(created);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Action with name ${codeInput.name} already exists`
);
});
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: undefined }
)
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for non-P2002 errors", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Database error when creating an action for environment ${codeInput.environmentId}`
);
});
});
describe("updateActionClass", () => {
const updateInput: Partial<TActionClassInput> = {
name: "Renamed Action",
description: "updated desc",
type: "code",
key: "renamed-key",
environmentId: "env-update",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should update and return the action class", async () => {
const updated = {
id: "id-update",
createdAt: new Date(),
updatedAt: new Date(),
name: updateInput.name,
description: updateInput.description ?? null,
type: "code" as const,
key: "renamed-key",
noCodeConfig: null,
environmentId: updateInput.environmentId,
surveyTriggers: [],
};
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
expect(result).toEqual(updated);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
`Action with name ${updateInput.name} already exists`
);
});
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "test",
})
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
DatabaseError
);
});
test("should rethrow unknown errors", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
"boom"
);
});
});
}); });
+3 -3
View File
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes"; import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants"; import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -135,7 +135,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation error.code === PrismaErrorType.UniqueConstraintViolation
) { ) {
const targetField = (error.meta?.target as string[] | undefined)?.[0]; const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError( throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists` `Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
); );
} }
@@ -185,7 +185,7 @@ export const updateActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation error.code === PrismaErrorType.UniqueConstraintViolation
) { ) {
const targetField = (error.meta?.target as string[] | undefined)?.[0]; const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError( throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists` `Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
); );
} }
+1
View File
@@ -182,6 +182,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO", "ro-RO",
"ru-RU", "ru-RU",
"sv-SE", "sv-SE",
"tr-TR",
"zh-Hans-CN", "zh-Hans-CN",
"zh-Hant-TW", "zh-Hant-TW",
]; ];
+7
View File
@@ -213,6 +213,13 @@ export const appLanguages = [
native: "Svenska", native: "Svenska",
}, },
}, },
{
code: "tr-TR",
label: {
"en-US": "Turkish",
native: "Türkçe",
},
},
{ {
code: "zh-Hans-CN", code: "zh-Hans-CN",
label: { label: {
+78
View File
@@ -6,11 +6,13 @@ import {
createEmailChangeToken, createEmailChangeToken,
createEmailToken, createEmailToken,
createInviteToken, createInviteToken,
createSsoRelinkIntent,
createToken, createToken,
createTokenForLinkSurvey, createTokenForLinkSurvey,
getEmailFromEmailToken, getEmailFromEmailToken,
verifyEmailChangeToken, verifyEmailChangeToken,
verifyInviteToken, verifyInviteToken,
verifySsoRelinkIntent,
verifyToken, verifyToken,
verifyTokenForLinkSurvey, verifyTokenForLinkSurvey,
} from "./jwt"; } from "./jwt";
@@ -380,6 +382,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({ expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email, email: mockUser.email,
purpose: "email_verification",
}); });
}); });
@@ -414,6 +417,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({ expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email, email: mockUser.email,
purpose: "email_verification",
}); });
}); });
@@ -425,6 +429,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({ expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email, 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 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 { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants"; 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) { if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set"); 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); 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 => { export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) { if (!NEXTAUTH_SECRET) {
@@ -224,7 +258,72 @@ const getUserEmailForLegacyVerification = async (
return { userId: decryptedId, userEmail: foundUser.email }; 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) { if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set"); 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 // Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id); 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 } => { export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
+1 -1
View File
@@ -63,7 +63,7 @@ const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): T
stripeCustomerId: billing.stripeCustomerId, stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits, limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor, 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 = globalThis.structuredClone;
const structuredCloneExport =
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
export { structuredCloneExport as structuredClone }; export { structuredCloneExport as structuredClone };
+64
View File
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFeatureFlag: vi.fn(),
posthog: {
__loaded: false,
getFeatureFlag: vi.fn(),
},
}));
vi.mock("posthog-js", () => ({
default: mocks.posthog,
}));
describe("getPostHogClientFeatureFlag", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.posthog.__loaded = false;
mocks.posthog.getFeatureFlag = mocks.getFeatureFlag;
});
test("returns false before PostHog is initialized", async () => {
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
});
test("returns true from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(true);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(true);
});
test("returns false from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(false);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
});
test("returns variant string from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue("variant-a");
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe("variant-a");
});
test("coerces undefined to false", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(undefined);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
});
});
+15
View File
@@ -0,0 +1,15 @@
"use client";
import posthog from "posthog-js";
import type { TPostHogFeatureFlagValue } from "./types";
export const getPostHogClientFeatureFlag = (flagKey: string): TPostHogFeatureFlagValue => {
if (!posthog.__loaded) {
return false;
}
const featureFlagValue = posthog.getFeatureFlag(flagKey);
return featureFlagValue ?? false;
};
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFeatureFlag: vi.fn(),
loggerWarn: vi.fn(),
}));
describe("getPostHogFeatureFlag", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
test("returns false when PostHog is not configured", async () => {
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: undefined }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
test("returns false when posthogServerClient is null", async () => {
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: null,
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
test("forwards distinctId, flagKey, and mapped groups to PostHog", async () => {
mocks.getFeatureFlag.mockResolvedValue(true);
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(
getPostHogFeatureFlag("user123", "experiment-flag", {
organizationId: "org_123",
workspaceId: "ws_456",
})
).resolves.toBe(true);
expect(mocks.getFeatureFlag).toHaveBeenCalledWith("experiment-flag", "user123", {
groups: {
organization: "org_123",
workspace: "ws_456",
},
});
});
test("preserves variant string responses", async () => {
mocks.getFeatureFlag.mockResolvedValue("variant-a");
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe("variant-a");
});
test("coerces undefined to false", async () => {
mocks.getFeatureFlag.mockResolvedValue(undefined);
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
});
test("logs and returns false when PostHog throws", async () => {
mocks.getFeatureFlag.mockRejectedValue(new Error("network error"));
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), flagKey: "experiment-flag" },
"Failed to evaluate PostHog feature flag"
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { POSTHOG_KEY } from "@/lib/constants";
import { posthogServerClient } from "./server";
import type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
const buildPostHogGroups = (context?: TPostHogFeatureFlagContext): Record<string, string> | undefined => {
const groups = {
...(context?.organizationId ? { organization: context.organizationId } : {}),
...(context?.workspaceId ? { workspace: context.workspaceId } : {}),
};
return Object.keys(groups).length > 0 ? groups : undefined;
};
export const getPostHogFeatureFlag = async (
distinctId: string,
flagKey: string,
context?: TPostHogFeatureFlagContext
): Promise<TPostHogFeatureFlagValue> => {
if (!POSTHOG_KEY || !posthogServerClient) {
return false;
}
try {
const featureFlagValue = await posthogServerClient.getFeatureFlag(flagKey, distinctId, {
groups: buildPostHogGroups(context),
});
return featureFlagValue ?? false;
} catch (error) {
logger.warn({ error, flagKey }, "Failed to evaluate PostHog feature flag");
return false;
}
};
+4
View File
@@ -1 +1,5 @@
import "server-only";
export { capturePostHogEvent } from "./capture"; export { capturePostHogEvent } from "./capture";
export { getPostHogFeatureFlag } from "./get-feature-flag";
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
+6
View File
@@ -0,0 +1,6 @@
export type TPostHogFeatureFlagValue = boolean | string;
export type TPostHogFeatureFlagContext = {
organizationId?: string;
workspaceId?: string;
};
+12 -7
View File
@@ -190,6 +190,14 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
showResponseCount: false, showResponseCount: false,
}; };
const mockBlocks = [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
];
const baseSurveyProperties = { const baseSurveyProperties = {
id: mockId, id: mockId,
name: "Mock Survey", name: "Mock Survey",
@@ -201,14 +209,9 @@ const baseSurveyProperties = {
displayLimit: 3, displayLimit: 3,
welcomeCard: mockWelcomeCard, welcomeCard: mockWelcomeCard,
questions: [], questions: [],
blocks: [ blocks: mockBlocks as unknown as SurveyMock["blocks"],
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
],
isBackButtonHidden: false, isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false, isCaptureIpEnabled: false,
endings: [ endings: [
{ {
@@ -303,6 +306,7 @@ export const createSurveyInput: TSurveyCreateInput = {
displayOption: "respondMultiple", displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }], triggers: [{ actionClass: mockActionClass }],
...baseSurveyProperties, ...baseSurveyProperties,
blocks: mockBlocks,
}; };
export const updateSurveyInput: TSurvey = { export const updateSurveyInput: TSurvey = {
@@ -325,6 +329,7 @@ export const updateSurveyInput: TSurvey = {
followUps: [], followUps: [],
...baseSurveyProperties, ...baseSurveyProperties,
...commonMockProperties, ...commonMockProperties,
blocks: mockBlocks,
slug: null, slug: null,
customHeadScripts: null, customHeadScripts: null,
customHeadScriptsMode: null, customHeadScriptsMode: null,
+7 -34
View File
@@ -5,7 +5,8 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; 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 { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import { import {
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
@@ -48,6 +49,7 @@ export const selectSurvey = {
isVerifyEmailEnabled: true, isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true, isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true, isBackButtonHidden: true,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: true, isCaptureIpEnabled: true,
redirectUrl: true, redirectUrl: true,
projectOverwrites: true, projectOverwrites: true,
@@ -557,22 +559,7 @@ export const updateSurveyInternal = async (
select: selectSurvey, select: selectSurvey,
}); });
let surveySegment: TSegment | null = null; return transformPrismaSurvey<TSurvey>(prismaSurvey);
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;
} catch (error) { } catch (error) {
logger.error(error, "Error updating survey"); logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -647,8 +634,8 @@ export const createSurvey = async (
} }
// Validate and prepare blocks for persistence // Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) { if (Array.isArray(data.blocks) && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks); data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
} }
const survey = await prisma.survey.create({ const survey = await prisma.survey.create({
@@ -772,21 +759,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
}); });
} }
let surveySegment: TSegment | null = null; return transformPrismaSurvey<TSurvey>(prismaSurvey);
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;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
+2 -1
View File
@@ -1,5 +1,5 @@
import { type Locale, formatDistance } from "date-fns"; 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 { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime"; import { formatDateForDisplay } from "./utils/datetime";
@@ -17,6 +17,7 @@ const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"ro-RO": ro, "ro-RO": ro,
"ru-RU": ru, "ru-RU": ru,
"sv-SE": sv, "sv-SE": sv,
"tr-TR": tr,
"zh-Hans-CN": zhCN, "zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW, "zh-Hant-TW": zhTW,
}; };
@@ -12,6 +12,7 @@ import {
OperationNotAllowedError, OperationNotAllowedError,
ResourceNotFoundError, ResourceNotFoundError,
TooManyRequestsError, TooManyRequestsError,
UniqueConstraintError,
UnknownError, UnknownError,
ValidationError, ValidationError,
isExpectedError, isExpectedError,
@@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
"OperationNotAllowedError", "OperationNotAllowedError",
"TooManyRequestsError", "TooManyRequestsError",
"InvalidPasswordResetTokenError", "InvalidPasswordResetTokenError",
"UniqueConstraintError",
]; ];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length); expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -91,6 +93,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: ValidationError, args: ["Invalid data"] }, { ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] }, { ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] }, { ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => { ])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args); const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true); expect(isExpectedError(error)).toBe(true);
@@ -186,6 +189,14 @@ describe("actionClient handleServerError", () => {
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE); expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
expect(Sentry.captureException).not.toHaveBeenCalled(); expect(Sentry.captureException).not.toHaveBeenCalled();
}); });
test("UniqueConstraintError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new UniqueConstraintError("Action with name foo already exists")
);
expect(result?.serverError).toBe("Action with name foo already exists");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
}); });
describe("unexpected errors SHOULD be reported to Sentry", () => { describe("unexpected errors SHOULD be reported to Sentry", () => {
+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; return match ? match[0] : null;
}; };
const getRecallItemLabel = <T extends TSurvey>( export const getRecallItemLabel = <T extends TSurvey>(
recallItemId: string, recallItemId: string,
survey: T, survey: T,
languageCode: string languageCode: string
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { isSafeIdentifier } from "./safe-identifier"; import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => { describe("safe-identifier", () => {
describe("isSafeIdentifier", () => { describe("isSafeIdentifier", () => {
@@ -32,4 +32,23 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false); 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); 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. * Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description" * Example: "job_description" -> "Job Description"
+47 -32
View File
@@ -111,7 +111,8 @@
}, },
"c": { "c": {
"link_expired": "Dein Link ist abgelaufen.", "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": { "common": {
"accepted": "Akzeptiert", "accepted": "Akzeptiert",
@@ -152,6 +153,7 @@
"centered_modal": "Zentriertes Modalfenster", "centered_modal": "Zentriertes Modalfenster",
"change_organization": "Organisation wechseln", "change_organization": "Organisation wechseln",
"change_workspace": "Workspace wechseln", "change_workspace": "Workspace wechseln",
"choice_n": "Auswahl {{n}}",
"choices": "Entscheidungen", "choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen", "choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen", "choose_organization": "Organisation auswählen",
@@ -165,6 +167,7 @@
"close": "Schließen", "close": "Schließen",
"code": "Code", "code": "Code",
"collapse_rows": "Zeilen einklappen", "collapse_rows": "Zeilen einklappen",
"column_n": "Spalte {{n}}",
"completed": "Abgeschlossen", "completed": "Abgeschlossen",
"configuration": "Konfiguration", "configuration": "Konfiguration",
"confirm": "Bestätigen", "confirm": "Bestätigen",
@@ -195,6 +198,7 @@
"created_by": "Erstellt von", "created_by": "Erstellt von",
"customer_success": "Kundenerfolg", "customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung", "dark_overlay": "Dunkle Überlagerung",
"data_refreshed_successfully": "Daten erfolgreich aktualisiert",
"date": "Datum", "date": "Datum",
"days": "Tage", "days": "Tage",
"default": "Standard", "default": "Standard",
@@ -236,6 +240,7 @@
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage", "failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen", "failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden", "failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"field_placeholder": "{{field}}-Platzhalter",
"filter": "Filter", "filter": "Filter",
"finish": "Fertigstellen", "finish": "Fertigstellen",
"first_name": "Vorname", "first_name": "Vorname",
@@ -247,10 +252,12 @@
"generate": "Generieren", "generate": "Generieren",
"go_back": "Geh zurück", "go_back": "Geh zurück",
"go_to_dashboard": "Zum Dashboard gehen", "go_to_dashboard": "Zum Dashboard gehen",
"headline": "Überschrift",
"hidden": "Versteckt", "hidden": "Versteckt",
"hidden_field": "Verstecktes Feld", "hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder", "hidden_fields": "Versteckte Felder",
"hide_column": "Spalte ausblenden", "hide_column": "Spalte ausblenden",
"html": "HTML",
"id": "ID", "id": "ID",
"image": "Bild", "image": "Bild",
"images": "Bilder", "images": "Bilder",
@@ -298,7 +305,6 @@
"months": "Monate", "months": "Monate",
"move_down": "Nach unten bewegen", "move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen", "move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
"my_product": "mein Produkt", "my_product": "mein Produkt",
"name": "Name", "name": "Name",
"new": "Neu", "new": "Neu",
@@ -313,6 +319,7 @@
"no_result_found": "Kein Ergebnis gefunden", "no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse", "no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.", "no_surveys_found": "Keine Umfragen gefunden.",
"no_text_found": "Kein Text gefunden",
"none_of_the_above": "Keine der oben genannten Optionen", "none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.", "not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht berechtigt", "not_authorized": "Nicht berechtigt",
@@ -336,7 +343,7 @@
"organization_settings": "Organisationseinstellungen", "organization_settings": "Organisationseinstellungen",
"other": "Andere", "other": "Andere",
"other_filters": "Weitere Filter", "other_filters": "Weitere Filter",
"others": "Andere", "other_placeholder": "Sonstiger Platzhalter",
"overlay_color": "Overlay-Farbe", "overlay_color": "Overlay-Farbe",
"overview": "Überblick", "overview": "Überblick",
"password": "Passwort", "password": "Passwort",
@@ -354,7 +361,6 @@
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan", "please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks", "powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau", "preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz", "privacy": "Datenschutz",
"product_manager": "Produktmanager", "product_manager": "Produktmanager",
"production": "Produktion", "production": "Produktion",
@@ -369,6 +375,7 @@
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.", "quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
"read_docs": "Dokumentation lesen", "read_docs": "Dokumentation lesen",
"recipients": "Empfänger", "recipients": "Empfänger",
"refresh": "Aktualisieren",
"remove": "Entfernen", "remove": "Entfernen",
"remove_from_team": "Aus Team entfernen", "remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden", "reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
@@ -381,6 +388,7 @@
"responses": "Antworten", "responses": "Antworten",
"restart": "Neustart", "restart": "Neustart",
"role": "Rolle", "role": "Rolle",
"row_n": "Zeile {{n}}",
"saas": "SaaS", "saas": "SaaS",
"sales": "Vertrieb", "sales": "Vertrieb",
"save": "Speichern", "save": "Speichern",
@@ -419,6 +427,7 @@
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen", "storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"string": "Text", "string": "Text",
"styling": "Styling", "styling": "Styling",
"subheader": "Unterüberschrift",
"submit": "Abschicken", "submit": "Abschicken",
"summary": "Zusammenfassung", "summary": "Zusammenfassung",
"survey": "Umfrage", "survey": "Umfrage",
@@ -487,7 +496,6 @@
"workspace_name_placeholder": "z. B. Formbricks", "workspace_name_placeholder": "z. B. Formbricks",
"workspaces": "Projekte", "workspaces": "Projekte",
"years": "Jahre", "years": "Jahre",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.", "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_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.", "you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
@@ -669,8 +677,6 @@
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt", "attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.", "attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht", "contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen", "create_attribute": "Attribut erstellen",
"create_new_attribute": "Neues Attribut erstellen", "create_new_attribute": "Neues Attribut erstellen",
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.", "create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
@@ -1193,7 +1199,7 @@
"organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!", "organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!",
"organization_name": "Organisationsname", "organization_name": "Organisationsname",
"organization_name_description": "Gib deiner Organisation einen Namen.", "organization_name_description": "Gib deiner Organisation einen Namen.",
"organization_name_placeholder": "z. B. Powerpuff Girls", "organization_name_placeholder": "z. B. Acme Inc.",
"organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert", "organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert",
"organization_settings": "Organisationseinstellungen", "organization_settings": "Organisationseinstellungen",
"please_add_a_logo": "Bitte füge ein Logo hinzu", "please_add_a_logo": "Bitte füge ein Logo hinzu",
@@ -1305,16 +1311,10 @@
"surveys": { "surveys": {
"all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen", "all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen",
"alphabetical": "alphabetisch", "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_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?", "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:", "activate_translations": "Übersetzungen aktivieren",
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
"add": "+ hinzufügen", "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_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", "add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu",
@@ -1363,6 +1363,8 @@
"assign": "Zuweisen =", "assign": "Zuweisen =",
"audience": "Publikum", "audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität", "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": "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_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", "auto_save_on": "Automatisches Speichern an",
@@ -1408,6 +1410,7 @@
"caution_text": "Änderungen werden zu Inkonsistenzen führen", "caution_text": "Änderungen werden zu Inkonsistenzen führen",
"change_anyway": "Trotzdem ändern", "change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern", "change_background": "Hintergrund ändern",
"change_default": "Standard ändern",
"change_question_type": "Fragetyp ändern", "change_question_type": "Fragetyp ändern",
"change_survey_type": "Die Änderung des Umfragetypen kann vorhandenen Zugriff beeinträchtigen", "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.", "change_the_background_to_a_color_image_or_animation": "Hintergrund zu einer Farbe, einem Bild oder einer Animation ändern.",
@@ -1420,6 +1423,7 @@
"choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.", "choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.",
"city": "Stadt", "city": "Stadt",
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen", "close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
"code": "Code",
"color": "Farbe", "color": "Farbe",
"column_used_in_logic_error": "Diese Spalte wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.", "column_used_in_logic_error": "Diese Spalte wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"columns": "Spalten", "columns": "Spalten",
@@ -1444,6 +1448,7 @@
"customize_survey_logo": "Umfragelogo anpassen", "customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.", "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.", "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_anyways": "Trotzdem löschen",
"delete_block": "Block löschen", "delete_block": "Block löschen",
"delete_choice": "Auswahl löschen", "delete_choice": "Auswahl löschen",
@@ -1463,7 +1468,6 @@
"duplicate_question": "Frage duplizieren", "duplicate_question": "Frage duplizieren",
"edit_link": "Bearbeitungslink", "edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten", "edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"element_not_found": "Frage nicht gefunden", "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_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.", "enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
@@ -1550,7 +1554,7 @@
"hide_question_settings": "Frageeinstellungen ausblenden", "hide_question_settings": "Frageeinstellungen ausblenden",
"hostname": "Hostname", "hostname": "Hostname",
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte", "if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.", "if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
"ignore_global_waiting_time": "Abkühlphase ignorieren", "ignore_global_waiting_time": "Abkühlphase ignorieren",
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.", "ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
"image": "Bild", "image": "Bild",
@@ -1600,10 +1604,12 @@
"long_answer_toggle_description": "Ermöglichen Sie den Befragten, längere Antworten über mehrere Zeilen zu schreiben.", "long_answer_toggle_description": "Ermöglichen Sie den Befragten, längere Antworten über mehrere Zeilen zu schreiben.",
"lower_label": "Unteres Label", "lower_label": "Unteres Label",
"manage_languages": "Sprachen verwalten", "manage_languages": "Sprachen verwalten",
"manage_translations": "Übersetzungen verwalten",
"matrix_all_fields": "Alle Felder", "matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen", "matrix_rows": "Zeilen",
"max_file_size": "Maximale Dateigröße", "max_file_size": "Maximale Dateigröße",
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt", "max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
"missing_first": "Fehlende zuerst",
"move_question_to_block": "Frage in Block verschieben", "move_question_to_block": "Frage in Block verschieben",
"multiply": "Multiplizieren *", "multiply": "Multiplizieren *",
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz", "needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
@@ -1611,7 +1617,7 @@
"next_button_label": "Beschriftung der Schaltfläche \"Weiter\"", "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_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_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_option_found": "Keine Option gefunden",
"no_recall_items_found": "Keine Recall-Elemente gefunden", "no_recall_items_found": "Keine Recall-Elemente gefunden",
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.", "no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
@@ -1638,6 +1644,7 @@
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)", "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_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
"please_specify": "Bitte angeben", "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": "Doppeltes Anbschicken verhindern",
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)", "prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
"progress_saved": "Fortschritt gespeichert", "progress_saved": "Fortschritt gespeichert",
@@ -1729,6 +1736,7 @@
"seven_points": "7 Punkte", "seven_points": "7 Punkte",
"show_block_settings": "Block-Einstellungen anzeigen", "show_block_settings": "Block-Einstellungen anzeigen",
"show_button": "Button anzeigen", "show_button": "Button anzeigen",
"show_in_order": "In Reihenfolge anzeigen",
"show_language_switch": "Sprachwechsel anzeigen", "show_language_switch": "Sprachwechsel anzeigen",
"show_multiple_times": "Begrenzte Anzahl von Malen anzeigen", "show_multiple_times": "Begrenzte Anzahl von Malen anzeigen",
"show_only_once": "Nur einmal anzeigen", "show_only_once": "Nur einmal anzeigen",
@@ -1760,7 +1768,6 @@
"survey_preview": "Umfragevorschau 👀", "survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling", "survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage", "survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
"target_block_not_found": "Zielblock nicht gefunden", "target_block_not_found": "Zielblock nicht gefunden",
"targeted": "Gezielt", "targeted": "Gezielt",
"ten_points": "10 Punkte", "ten_points": "10 Punkte",
@@ -1768,9 +1775,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.", "the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
"then": "dann", "then": "dann",
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.", "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", "three_points": "3 Punkte",
"times": "Zeiten", "times": "Zeiten",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du", "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...", "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'...", "try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"type_field_id": "Feld-ID eingeben", "type_field_id": "Feld-ID eingeben",
@@ -1845,6 +1854,7 @@
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.", "verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme", "visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.", "visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
"visible": "Sichtbar",
"wait": "Warte", "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", "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)", "waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
@@ -2053,7 +2063,6 @@
"downloading_qr_code": "QR-Code wird heruntergeladen", "downloading_qr_code": "QR-Code wird heruntergeladen",
"drop_offs": "Drop-Off Rate", "drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.", "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_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert", "filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)", "filtered_responses_csv": "Gefilterte Antworten (CSV)",
@@ -2141,7 +2150,6 @@
}, },
"survey_deleted_successfully": "Umfrage erfolgreich gelöscht", "survey_deleted_successfully": "Umfrage erfolgreich gelöscht",
"survey_duplicated_successfully": "Umfrage erfolgreich dupliziert", "survey_duplicated_successfully": "Umfrage erfolgreich dupliziert",
"survey_duplication_error": "Duplizieren der Umfrage fehlgeschlagen",
"templates": { "templates": {
"all_channels": "Alle Kanäle", "all_channels": "Alle Kanäle",
"all_industries": "Alle Branchen", "all_industries": "Alle Branchen",
@@ -2229,7 +2237,6 @@
"duplicate_language_or_language_id": "Doppelte Sprache oder Sprach-ID", "duplicate_language_or_language_id": "Doppelte Sprache oder Sprach-ID",
"edit_languages": "Sprachen bearbeiten", "edit_languages": "Sprachen bearbeiten",
"identifier": "Kennung (ISO)", "identifier": "Kennung (ISO)",
"incomplete_translations": "Unvollständige Übersetzungen",
"language": "Sprache", "language": "Sprache",
"language_deleted_successfully": "Sprache erfolgreich gelöscht", "language_deleted_successfully": "Sprache erfolgreich gelöscht",
"languages_updated_successfully": "Sprachen erfolgreich aktualisiert", "languages_updated_successfully": "Sprachen erfolgreich aktualisiert",
@@ -2239,8 +2246,7 @@
"please_select_a_language": "Bitte wähle eine Sprache aus", "please_select_a_language": "Bitte wähle eine Sprache aus",
"remove_language": "Sprache entfernen", "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.", "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", "search_items": "Elemente suchen"
"translate": "Übersetzen"
}, },
"look": { "look": {
"add_background_color": "Hintergrundfarbe hinzufügen", "add_background_color": "Hintergrundfarbe hinzufügen",
@@ -2300,12 +2306,12 @@
"advanced_styling_field_track_bg_description": "Färbt den nicht ausgefüllten Teil des Balkens.", "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": "Track-Höhe",
"advanced_styling_field_track_height_description": "Steuert die Dicke des Fortschrittsbalkens.", "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": "Labelfarbe",
"advanced_styling_field_upper_label_color_description": "Färbt die kleine Beschriftung über Eingabefeldern.", "advanced_styling_field_upper_label_color_description": "Färbt die kleinen Labels über Eingabefeldern und Skalenbeschriftungen ein.",
"advanced_styling_field_upper_label_size": "Schriftgröße des oberen Labels", "advanced_styling_field_upper_label_size": "Label-Schriftgröße",
"advanced_styling_field_upper_label_size_description": "Skaliert die kleine Beschriftung über Eingabefeldern.", "advanced_styling_field_upper_label_size_description": "Skaliert die kleinen Labels über Eingabefeldern und Skalenbeschriftungen.",
"advanced_styling_field_upper_label_weight": "Schriftstärke des oberen Labels", "advanced_styling_field_upper_label_weight": "Label-Schriftstärke",
"advanced_styling_field_upper_label_weight_description": "Macht die Beschriftung leichter oder fetter.", "advanced_styling_field_upper_label_weight_description": "Macht die Labels dünner oder fetter.",
"advanced_styling_section_buttons": "Buttons", "advanced_styling_section_buttons": "Buttons",
"advanced_styling_section_headlines": "Überschriften & Beschreibungen", "advanced_styling_section_headlines": "Überschriften & Beschreibungen",
"advanced_styling_section_inputs": "Eingabefelder", "advanced_styling_section_inputs": "Eingabefelder",
@@ -2441,7 +2447,9 @@
"verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten", "verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten",
"verify_email_before_submission_button": "Überprüfen", "verify_email_before_submission_button": "Überprüfen",
"verify_email_before_submission_description": "Um an dieser Umfrage teilzunehmen, bitte bestätige deine E-Mail", "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": { "setup": {
"intro": { "intro": {
@@ -2876,6 +2884,11 @@
"gauge_feature_satisfaction_question_2_headline": "Was könnten wir besser machen?", "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_description": "Besser verstehen, ob deine Botschaften die richtigen Erwartungen an dein Produkt schaffen.",
"identify_customer_goals_name": "Kundenziele identifizieren", "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_description": "Biete einen Rabatt an, um Einblicke in Anmeldebarrieren zu gewinnen.",
"identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren", "identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren",
"identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt", "identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt",
@@ -2950,12 +2963,14 @@
"improve_trial_conversion_question_1_subheader": "Hilf uns, Dich besser zu verstehen:", "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_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_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_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_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_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_button_label": "Weiter",
"improve_trial_conversion_question_5_headline": "Was möchtest Du erreichen?", "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_headline": "Wie löst Du dein Problem heutzutage?",
"improve_trial_conversion_question_6_subheader": "Bitte nenne alternative Lösungen:", "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.", "integration_setup_survey_description": "Bewerte, wie einfach Nutzer Integrationen zu deinem Produkt hinzufügen können.",
+48 -33
View File
@@ -111,7 +111,8 @@
}, },
"c": { "c": {
"link_expired": "Your link is expired.", "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": { "common": {
"accepted": "Accepted", "accepted": "Accepted",
@@ -152,6 +153,7 @@
"centered_modal": "Centered Modal", "centered_modal": "Centered Modal",
"change_organization": "Change organization", "change_organization": "Change organization",
"change_workspace": "Change workspace", "change_workspace": "Change workspace",
"choice_n": "Choice {{n}}",
"choices": "Choices", "choices": "Choices",
"choose_environment": "Choose environment", "choose_environment": "Choose environment",
"choose_organization": "Choose organization", "choose_organization": "Choose organization",
@@ -165,6 +167,7 @@
"close": "Close", "close": "Close",
"code": "Code", "code": "Code",
"collapse_rows": "Collapse rows", "collapse_rows": "Collapse rows",
"column_n": "Column {{n}}",
"completed": "Completed", "completed": "Completed",
"configuration": "Configuration", "configuration": "Configuration",
"confirm": "Confirm", "confirm": "Confirm",
@@ -195,6 +198,7 @@
"created_by": "Created by", "created_by": "Created by",
"customer_success": "Customer Success", "customer_success": "Customer Success",
"dark_overlay": "Dark overlay", "dark_overlay": "Dark overlay",
"data_refreshed_successfully": "Data refreshed successfully",
"date": "Date", "date": "Date",
"days": "days", "days": "days",
"default": "Default", "default": "Default",
@@ -236,6 +240,7 @@
"failed_to_copy_to_clipboard": "Failed to copy to clipboard", "failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"failed_to_load_organizations": "Failed to load organizations", "failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces", "failed_to_load_workspaces": "Failed to load workspaces",
"field_placeholder": "{{field}} Placeholder",
"filter": "Filter", "filter": "Filter",
"finish": "Finish", "finish": "Finish",
"first_name": "First Name", "first_name": "First Name",
@@ -247,10 +252,12 @@
"generate": "Generate", "generate": "Generate",
"go_back": "Go Back", "go_back": "Go Back",
"go_to_dashboard": "Go to Dashboard", "go_to_dashboard": "Go to Dashboard",
"headline": "Headline",
"hidden": "Hidden", "hidden": "Hidden",
"hidden_field": "Hidden field", "hidden_field": "Hidden field",
"hidden_fields": "Hidden fields", "hidden_fields": "Hidden fields",
"hide_column": "Hide column", "hide_column": "Hide column",
"html": "HTML",
"id": "ID", "id": "ID",
"image": "Image", "image": "Image",
"images": "Images", "images": "Images",
@@ -298,7 +305,6 @@
"months": "months", "months": "months",
"move_down": "Move down", "move_down": "Move down",
"move_up": "Move up", "move_up": "Move up",
"multiple_languages": "Multiple languages",
"my_product": "my Product", "my_product": "my Product",
"name": "Name", "name": "Name",
"new": "New", "new": "New",
@@ -313,6 +319,7 @@
"no_result_found": "No result found", "no_result_found": "No result found",
"no_results": "No results", "no_results": "No results",
"no_surveys_found": "No surveys found.", "no_surveys_found": "No surveys found.",
"no_text_found": "No text found",
"none_of_the_above": "None of the above", "none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.", "not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized", "not_authorized": "Not authorized",
@@ -336,7 +343,7 @@
"organization_settings": "Organization settings", "organization_settings": "Organization settings",
"other": "Other", "other": "Other",
"other_filters": "Other Filters", "other_filters": "Other Filters",
"others": "Others", "other_placeholder": "Other Placeholder",
"overlay_color": "Overlay color", "overlay_color": "Overlay color",
"overview": "Overview", "overview": "Overview",
"password": "Password", "password": "Password",
@@ -354,7 +361,6 @@
"please_upgrade_your_plan": "Please upgrade your plan", "please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks", "powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview", "preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
"product_manager": "Product Manager", "product_manager": "Product Manager",
"production": "Production", "production": "Production",
@@ -369,6 +375,7 @@
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.", "quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read docs", "read_docs": "Read docs",
"recipients": "Recipients", "recipients": "Recipients",
"refresh": "Refresh",
"remove": "Remove", "remove": "Remove",
"remove_from_team": "Remove from team", "remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns", "reorder_and_hide_columns": "Reorder and hide columns",
@@ -381,6 +388,7 @@
"responses": "Responses", "responses": "Responses",
"restart": "Restart", "restart": "Restart",
"role": "Role", "role": "Role",
"row_n": "Row {{n}}",
"saas": "SaaS", "saas": "SaaS",
"sales": "Sales", "sales": "Sales",
"save": "Save", "save": "Save",
@@ -419,6 +427,7 @@
"storage_not_configured": "File storage not set up, uploads will likely fail", "storage_not_configured": "File storage not set up, uploads will likely fail",
"string": "Text", "string": "Text",
"styling": "Styling", "styling": "Styling",
"subheader": "Subheader",
"submit": "Submit", "submit": "Submit",
"summary": "Summary", "summary": "Summary",
"survey": "Survey", "survey": "Survey",
@@ -487,7 +496,6 @@
"workspace_name_placeholder": "e.g. Formbricks", "workspace_name_placeholder": "e.g. Formbricks",
"workspaces": "Workspaces", "workspaces": "Workspaces",
"years": "years", "years": "years",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.", "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_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.", "you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {projectLimit} workspaces.",
@@ -669,8 +677,6 @@
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”", "attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.", "attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully", "contact_deleted_successfully": "Contact deleted successfully",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute", "create_attribute": "Create attribute",
"create_new_attribute": "Create new attribute", "create_new_attribute": "Create new attribute",
"create_new_attribute_description": "Create a new attribute for segmentation purposes.", "create_new_attribute_description": "Create a new attribute for segmentation purposes.",
@@ -1193,7 +1199,7 @@
"organization_invite_link_ready": "Your organization invite link is ready!", "organization_invite_link_ready": "Your organization invite link is ready!",
"organization_name": "Organization Name", "organization_name": "Organization Name",
"organization_name_description": "Give your organization a descriptive name.", "organization_name_description": "Give your organization a descriptive name.",
"organization_name_placeholder": "e.g. Power Puff Girls", "organization_name_placeholder": "e.g. Acme Inc.",
"organization_name_updated_successfully": "Organization name updated successfully", "organization_name_updated_successfully": "Organization name updated successfully",
"organization_settings": "Organization Settings", "organization_settings": "Organization Settings",
"please_add_a_logo": "Please add a logo", "please_add_a_logo": "Please add a logo",
@@ -1305,16 +1311,10 @@
"surveys": { "surveys": {
"all_set_time_to_create_first_survey": "You are all set! Time to create your first survey", "all_set_time_to_create_first_survey": "You are all set! Time to create your first survey",
"alphabetical": "Alphabetical", "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_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?", "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:", "activate_translations": "Activate translations",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
"add": "Add +", "add": "Add +",
"add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey", "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", "add_a_four_digit_pin": "Add a four digit PIN",
@@ -1363,6 +1363,8 @@
"assign": "Assign =", "assign": "Assign =",
"audience": "Audience", "audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity", "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": "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_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", "auto_save_on": "Auto-save on",
@@ -1408,6 +1410,7 @@
"caution_text": "Changes will lead to inconsistencies", "caution_text": "Changes will lead to inconsistencies",
"change_anyway": "Change anyway", "change_anyway": "Change anyway",
"change_background": "Change background", "change_background": "Change background",
"change_default": "Change default",
"change_question_type": "Change question type", "change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access", "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.", "change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
@@ -1420,6 +1423,7 @@
"choose_where_to_run_the_survey": "Choose where to run the survey.", "choose_where_to_run_the_survey": "Choose where to run the survey.",
"city": "City", "city": "City",
"close_survey_on_response_limit": "Close survey on response limit", "close_survey_on_response_limit": "Close survey on response limit",
"code": "Code",
"color": "Color", "color": "Color",
"column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.", "column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.",
"columns": "Columns", "columns": "Columns",
@@ -1444,6 +1448,7 @@
"customize_survey_logo": "Customize the survey logo", "customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.", "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.", "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_anyways": "Delete anyways",
"delete_block": "Delete block", "delete_block": "Delete block",
"delete_choice": "Delete choice", "delete_choice": "Delete choice",
@@ -1463,7 +1468,6 @@
"duplicate_question": "Duplicate question", "duplicate_question": "Duplicate question",
"edit_link": "Edit link", "edit_link": "Edit link",
"edit_recall": "Edit Recall", "edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"element_not_found": "Question not found", "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_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.", "enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
@@ -1550,7 +1554,7 @@
"hide_question_settings": "Hide Question settings", "hide_question_settings": "Hide Question settings",
"hostname": "Hostname", "hostname": "Hostname",
"if_you_need_more_please": "If you need more, please", "if_you_need_more_please": "If you need more, please",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.", "if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
"ignore_global_waiting_time": "Ignore Cooldown Period", "ignore_global_waiting_time": "Ignore Cooldown Period",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.", "ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image", "image": "Image",
@@ -1599,11 +1603,13 @@
"long_answer": "Long answer", "long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.", "long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label", "lower_label": "Lower Label",
"manage_languages": "Manage Languages", "manage_languages": "Manage languages",
"manage_translations": "Manage translations",
"matrix_all_fields": "All fields", "matrix_all_fields": "All fields",
"matrix_rows": "Rows", "matrix_rows": "Rows",
"max_file_size": "Max file size", "max_file_size": "Max file size",
"max_file_size_limit_is": "Max file size limit is", "max_file_size_limit_is": "Max file size limit is",
"missing_first": "Missing first",
"move_question_to_block": "Move question to block", "move_question_to_block": "Move question to block",
"multiply": "Multiply *", "multiply": "Multiply *",
"needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance", "needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance",
@@ -1611,7 +1617,7 @@
"next_button_label": "“Next” button label", "next_button_label": "“Next” button label",
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.", "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_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_option_found": "No option found",
"no_recall_items_found": "No recall items found", "no_recall_items_found": "No recall items found",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.", "no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
@@ -1638,6 +1644,7 @@
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)", "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_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify", "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": "Prevent double submission",
"prevent_double_submission_description": "Only allow 1 response per email address", "prevent_double_submission_description": "Only allow 1 response per email address",
"progress_saved": "Progress saved", "progress_saved": "Progress saved",
@@ -1729,6 +1736,7 @@
"seven_points": "7 points", "seven_points": "7 points",
"show_block_settings": "Show Block settings", "show_block_settings": "Show Block settings",
"show_button": "Show Button", "show_button": "Show Button",
"show_in_order": "Show in order",
"show_language_switch": "Show language switch", "show_language_switch": "Show language switch",
"show_multiple_times": "Show a limited number of times", "show_multiple_times": "Show a limited number of times",
"show_only_once": "Show only once", "show_only_once": "Show only once",
@@ -1760,7 +1768,6 @@
"survey_preview": "Survey Preview 👀", "survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling", "survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger", "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", "target_block_not_found": "Target block not found",
"targeted": "Targeted", "targeted": "Targeted",
"ten_points": "10 points", "ten_points": "10 points",
@@ -1768,9 +1775,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they do not respond.", "the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they do not respond.",
"then": "Then", "then": "Then",
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.", "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", "three_points": "3 points",
"times": "times", "times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can", "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…", "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”…", "try_lollipop_or_mountain": "Try “lollipop” or “mountain”…",
"type_field_id": "Type field id", "type_field_id": "Type field id",
@@ -1845,6 +1854,7 @@
"verify_email_before_submission_description": "Only let people with a real email respond.", "verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact", "visibility_and_recontact": "Visibility & Recontact",
"visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.", "visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.",
"visible": "Visible",
"wait": "Wait", "wait": "Wait",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey", "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)", "waiting_time_across_surveys": "Cooldown Period (across surveys)",
@@ -2053,7 +2063,6 @@
"downloading_qr_code": "Downloading QR code", "downloading_qr_code": "Downloading QR code",
"drop_offs": "Drop-Offs", "drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.", "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_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully", "filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)", "filtered_responses_csv": "Filtered responses (CSV)",
@@ -2141,7 +2150,6 @@
}, },
"survey_deleted_successfully": "Survey deleted successfully", "survey_deleted_successfully": "Survey deleted successfully",
"survey_duplicated_successfully": "Survey duplicated successfully", "survey_duplicated_successfully": "Survey duplicated successfully",
"survey_duplication_error": "Failed to duplicate the survey.",
"templates": { "templates": {
"all_channels": "All channels", "all_channels": "All channels",
"all_industries": "All industries", "all_industries": "All industries",
@@ -2229,7 +2237,6 @@
"duplicate_language_or_language_id": "Duplicate language or language ID", "duplicate_language_or_language_id": "Duplicate language or language ID",
"edit_languages": "Edit languages", "edit_languages": "Edit languages",
"identifier": "Identifier (ISO)", "identifier": "Identifier (ISO)",
"incomplete_translations": "Incomplete translations",
"language": "Language", "language": "Language",
"language_deleted_successfully": "Language deleted successfully", "language_deleted_successfully": "Language deleted successfully",
"languages_updated_successfully": "Languages updated successfully", "languages_updated_successfully": "Languages updated successfully",
@@ -2239,8 +2246,7 @@
"please_select_a_language": "Please select a language", "please_select_a_language": "Please select a language",
"remove_language": "Remove 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.", "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", "search_items": "Search items"
"translate": "Translate"
}, },
"look": { "look": {
"add_background_color": "Add background color", "add_background_color": "Add background color",
@@ -2300,12 +2306,12 @@
"advanced_styling_field_track_bg_description": "Colors the unfilled portion of the bar.", "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": "Track Height",
"advanced_styling_field_track_height_description": "Controls the progress bar thickness.", "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": "Label Color",
"advanced_styling_field_upper_label_color_description": "Colors the small label above inputs.", "advanced_styling_field_upper_label_color_description": "Colors the small labels above inputs and scale labels.",
"advanced_styling_field_upper_label_size": "Headline Label Font Size", "advanced_styling_field_upper_label_size": "Label Font Size",
"advanced_styling_field_upper_label_size_description": "Scales the small label above inputs.", "advanced_styling_field_upper_label_size_description": "Scales the small labels above inputs and scale labels.",
"advanced_styling_field_upper_label_weight": "Headline Label Font Weight", "advanced_styling_field_upper_label_weight": "Label Font Weight",
"advanced_styling_field_upper_label_weight_description": "Makes the label lighter or bolder.", "advanced_styling_field_upper_label_weight_description": "Makes the labels lighter or bolder.",
"advanced_styling_section_buttons": "Buttons", "advanced_styling_section_buttons": "Buttons",
"advanced_styling_section_headlines": "Headlines & Descriptions", "advanced_styling_section_headlines": "Headlines & Descriptions",
"advanced_styling_section_inputs": "Inputs", "advanced_styling_section_inputs": "Inputs",
@@ -2441,7 +2447,9 @@
"verify_email_before_submission": "Verify your email to respond", "verify_email_before_submission": "Verify your email to respond",
"verify_email_before_submission_button": "Verify", "verify_email_before_submission_button": "Verify",
"verify_email_before_submission_description": "To respond to this survey, please verify your email", "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": { "setup": {
"intro": { "intro": {
@@ -2876,6 +2884,11 @@
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?", "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_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_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_description": "Offer a discount to gather insights about sign up barriers.",
"identify_sign_up_barriers_name": "Identify Sign Up Barriers", "identify_sign_up_barriers_name": "Identify Sign Up Barriers",
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount", "identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
@@ -2950,12 +2963,14 @@
"improve_trial_conversion_question_1_subheader": "Help us understand you better:", "improve_trial_conversion_question_1_subheader": "Help us understand you better:",
"improve_trial_conversion_question_2_button_label": "Next", "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_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_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_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_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_button_label": "Next",
"improve_trial_conversion_question_5_headline": "What would you like to achieve?", "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_headline": "How are you solving your problem now?",
"improve_trial_conversion_question_6_subheader": "Please name alternative solutions:", "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.", "integration_setup_survey_description": "Evaluate how easily users can add integrations to your product. Find blind spots.",

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