diff --git a/.env.example b/.env.example
index b4f51e2def..8d66de1e91 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,9 @@ NEXTAUTH_SECRET=
# You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET=
+# Set the minimum log level(debug, info, warn, error, fatal)
+LOG_LEVEL=info
+
##############
# DATABASE #
##############
@@ -77,6 +80,9 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0
+# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
+# SURVEY_URL=https://survey.example.com
+
#####################
# Disable Features #
#####################
diff --git a/.github/actions/cache-build-web/action.yml b/.github/actions/cache-build-web/action.yml
index 6ae00d0203..25d18f4245 100644
--- a/.github/actions/cache-build-web/action.yml
+++ b/.github/actions/cache-build-web/action.yml
@@ -57,9 +57,6 @@ runs:
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
- sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
- sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..d27ee547a7
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,84 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
+ directory: "/" # Root package.json
+ schedule:
+ interval: "weekly"
+ versioning-strategy: increase
+
+ # Apps directory packages
+ - package-ecosystem: "npm"
+ directory: "/apps/demo"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/apps/demo-react-native"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/apps/storybook"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/apps/web"
+ schedule:
+ interval: "weekly"
+
+ # Packages directory
+ - package-ecosystem: "npm"
+ directory: "/packages/database"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/lib"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/types"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/config-eslint"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/config-prettier"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/config-typescript"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/js-core"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/surveys"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/packages/logger"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/cron-surveyStatusUpdate.yml b/.github/workflows/cron-surveyStatusUpdate.yml
deleted file mode 100644
index 7563067db5..0000000000
--- a/.github/workflows/cron-surveyStatusUpdate.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: Cron - Survey status update
-
-on:
- workflow_dispatch:
- # "Scheduled workflows run on the latest commit on the default or base branch."
- # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
- schedule:
- # Runs "At 00:00." (see https://crontab.guru)
- - cron: "0 0 * * *"
-
-permissions:
- contents: read
-
-jobs:
- cron-weeklySummary:
- env:
- APP_URL: ${{ secrets.APP_URL }}
- CRON_SECRET: ${{ secrets.CRON_SECRET }}
- runs-on: ubuntu-latest
- steps:
- - name: Harden the runner (Audit all outbound calls)
- uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
- with:
- egress-policy: audit
-
- - name: cURL request
- if: ${{ env.APP_URL && env.CRON_SECRET }}
- run: |
- curl ${{ env.APP_URL }}/api/cron/survey-status \
- -X POST \
- -H 'content-type: application/json' \
- -H 'x-api-key: ${{ env.CRON_SECRET }}' \
- --fail
diff --git a/.github/workflows/cron-weeklySummary.yml b/.github/workflows/cron-weeklySummary.yml
deleted file mode 100644
index dc570a7cf9..0000000000
--- a/.github/workflows/cron-weeklySummary.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: Cron - Weekly summary
-
-on:
- workflow_dispatch:
- # "Scheduled workflows run on the latest commit on the default or base branch."
- # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
- schedule:
- # Runs “At 08:00 on Monday.” (see https://crontab.guru)
- - cron: "0 8 * * 1"
-permissions:
- contents: read
-
-jobs:
- cron-weeklySummary:
- permissions:
- contents: read
- env:
- APP_URL: ${{ secrets.APP_URL }}
- CRON_SECRET: ${{ secrets.CRON_SECRET }}
- runs-on: ubuntu-latest
- steps:
- - name: Harden the runner (Audit all outbound calls)
- uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
- with:
- egress-policy: audit
- - name: cURL request
- if: ${{ env.APP_URL && env.CRON_SECRET }}
- run: |
- curl ${{ env.APP_URL }}/api/cron/weekly-summary \
- -X POST \
- -H 'content-type: application/json' \
- -H 'x-api-key: ${{ env.CRON_SECRET }}' \
- --fail
diff --git a/.github/workflows/deploy-formbricks-cloud.yml b/.github/workflows/deploy-formbricks-cloud.yml
new file mode 100644
index 0000000000..bff5c196e3
--- /dev/null
+++ b/.github/workflows/deploy-formbricks-cloud.yml
@@ -0,0 +1,64 @@
+name: Formbricks Cloud Deployment
+
+on:
+ workflow_dispatch:
+ inputs:
+ VERSION:
+ description: 'The version of the Docker image to release'
+ required: true
+ type: string
+ REPOSITORY:
+ description: 'The repository to use for the Docker image'
+ required: false
+ type: string
+ default: 'ghcr.io/formbricks/formbricks'
+ workflow_call:
+ inputs:
+ VERSION:
+ description: 'The version of the Docker image to release'
+ required: true
+ type: string
+ REPOSITORY:
+ description: 'The repository to use for the Docker image'
+ required: false
+ type: string
+ default: 'ghcr.io/formbricks/formbricks'
+
+permissions:
+ id-token: write
+ contents: write
+
+jobs:
+ helmfile-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
+ with:
+ role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
+ aws-region: "eu-central-1"
+
+ - name: Setup Cluster Access
+ run: |
+ aws eks update-kubeconfig --name formbricks-prod-eks --region eu-central-1
+ env:
+ AWS_REGION: eu-central-1
+
+ - uses: helmfile/helmfile-action@v2
+ env:
+ VERSION: ${{ inputs.VERSION }}
+ REPOSITORY: ${{ inputs.REPOSITORY }}
+ FORMBRICKS_S3_BUCKET: ${{ secrets.FORMBRICKS_S3_BUCKET }}
+ FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
+ FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
+ with:
+ helm-plugins: >
+ https://github.com/databus23/helm-diff,
+ https://github.com/jkroepke/helm-secrets
+ helmfile-args: apply
+ helmfile-auto-init: "false"
+ helmfile-workdirectory: infra/formbricks-cloud-helm
+
diff --git a/.github/workflows/formbricks-release.yml b/.github/workflows/formbricks-release.yml
new file mode 100644
index 0000000000..dca6cfd53f
--- /dev/null
+++ b/.github/workflows/formbricks-release.yml
@@ -0,0 +1,33 @@
+name: Build, release & deploy Formbricks images
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ docker-build:
+ name: Build & release stable docker image
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/workflows/release-docker-github.yml
+ secrets: inherit
+
+ helm-chart-release:
+ name: Release Helm Chart
+ uses: ./.github/workflows/release-helm-chart.yml
+ secrets: inherit
+ needs:
+ - docker-build
+ with:
+ VERSION: ${{ needs.docker-build.outputs.VERSION }}
+
+ deploy-formbricks-cloud:
+ name: Deploy Helm Chart to Formbricks Cloud
+ secrets: inherit
+ uses: ./.github/workflows/deploy-formbricks-cloud.yml
+ needs:
+ - docker-build
+ - helm-chart-release
+ with:
+ VERSION: ${{ needs.docker-build.outputs.VERSION }}
diff --git a/.github/workflows/release-docker-github-experimental.yml b/.github/workflows/release-docker-github-experimental.yml
index c009debdcd..25b8e5e61e 100644
--- a/.github/workflows/release-docker-github-experimental.yml
+++ b/.github/workflows/release-docker-github-experimental.yml
@@ -15,7 +15,6 @@ env:
IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions:
contents: read
@@ -80,6 +79,9 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+ secrets: |
+ database_url=${{ secrets.DUMMY_DATABASE_URL }}
+ encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/release-docker-github.yml b/.github/workflows/release-docker-github.yml
index 837dcfe4b5..457940fb7e 100644
--- a/.github/workflows/release-docker-github.yml
+++ b/.github/workflows/release-docker-github.yml
@@ -6,10 +6,11 @@ name: Docker Release to Github
# documentation.
on:
- workflow_dispatch:
- push:
- tags:
- - "v*"
+ workflow_call:
+ outputs:
+ VERSION:
+ description: release version
+ value: ${{ jobs.build.outputs.VERSION }}
env:
# Use docker.io for Docker Hub if empty
@@ -18,7 +19,6 @@ env:
IMAGE_NAME: ${{ github.repository }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions:
contents: read
@@ -33,6 +33,9 @@ jobs:
# with sigstore/fulcio when running outside of PRs.
id-token: write
+ outputs:
+ VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
+
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
@@ -48,6 +51,7 @@ jobs:
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
+ echo "VERSION=$TAG" >> $GITHUB_OUTPUT
- name: Update package.json version
run: |
@@ -95,6 +99,9 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+ secrets: |
+ database_url=${{ secrets.DUMMY_DATABASE_URL }}
+ encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml
deleted file mode 100644
index d3367333ca..0000000000
--- a/.github/workflows/release-docker.yml
+++ /dev/null
@@ -1,59 +0,0 @@
-name: Release on Dockerhub
-
-on:
- push:
- tags:
- - "v*"
-
-permissions:
- contents: read
-
-jobs:
- release-image-on-dockerhub:
- name: Release on Dockerhub
- permissions:
- contents: read
- runs-on: ubuntu-latest
- env:
- TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
- TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
- steps:
- - name: Harden the runner (Audit all outbound calls)
- uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
- with:
- egress-policy: audit
-
- - name: Checkout Repo
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
-
- - name: Get Release Tag
- id: extract_release_tag
- run: |
- TAG=${{ github.ref }}
- TAG=${TAG#refs/tags/v}
- echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
-
- - name: Update package.json version
- run: |
- sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
- cat ./apps/web/package.json | grep version
-
- - name: Log in to Docker Hub
- uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
- with:
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_PASSWORD }}
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
-
- - name: Build and push Docker image
- uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1
- with:
- context: .
- file: ./apps/web/Dockerfile
- push: true
- tags: |
- ${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
- ${{ secrets.DOCKER_USERNAME }}/formbricks:latest
diff --git a/.github/workflows/release-helm-chart.yml b/.github/workflows/release-helm-chart.yml
index 85cfe16894..fbe39e160d 100644
--- a/.github/workflows/release-helm-chart.yml
+++ b/.github/workflows/release-helm-chart.yml
@@ -1,9 +1,12 @@
name: Publish Helm Chart
on:
- release:
- types:
- - published
+ workflow_call:
+ inputs:
+ VERSION:
+ description: 'The version of the Helm chart to release'
+ required: true
+ type: string
permissions:
contents: read
@@ -39,8 +42,8 @@ jobs:
- name: Update Chart.yaml with new version
run: |
- yq -i ".version = \"${VERSION#v}\"" helm-chart/Chart.yaml
- yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
+ yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
+ yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -48,4 +51,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
- helm push formbricks-${VERSION#v}.tgz oci://ghcr.io/formbricks/helm-charts
+ helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml
index 65d26fe7b7..b7dfe776df 100644
--- a/.github/workflows/sonarqube.yml
+++ b/.github/workflows/sonarqube.yml
@@ -46,11 +46,7 @@ jobs:
- name: Run tests with coverage
run: |
- cd apps/web
pnpm test:coverage
- cd ../../
- # The Vitest coverage config is in your vite.config.mts
-
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
env:
diff --git a/.github/workflows/terrafrom-plan-and-apply.yml b/.github/workflows/terrafrom-plan-and-apply.yml
index 49f2b99081..78d0c72e6c 100644
--- a/.github/workflows/terrafrom-plan-and-apply.yml
+++ b/.github/workflows/terrafrom-plan-and-apply.yml
@@ -3,16 +3,21 @@ name: 'Terraform'
on:
workflow_dispatch:
# TODO: enable it back when migration is completed.
-# push:
-# branches:
-# - main
-# pull_request:
-# branches:
-# - main
+ push:
+ branches:
+ - main
+ paths:
+ - "infra/terraform/**"
+ pull_request:
+ branches:
+ - main
+ paths:
+ - "infra/terraform/**"
permissions:
id-token: write
contents: write
+ pull-requests: write
jobs:
terraform:
@@ -58,18 +63,17 @@ jobs:
run: terraform plan -out .planfile
working-directory: infra/terraform
-# - name: Post PR comment
-# uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
-# if: always() && github.ref != 'refs/heads/main' && (steps.validate.outcome == 'success' || steps.validate.outcome == 'failure')
-# with:
-# token: ${{ github.token }}
-# planfile: .planfile
-# working-directory: "infra/terraform"
-# skip-comment: true
+ - name: Post PR comment
+ uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
+ if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
+ with:
+ token: ${{ github.token }}
+ planfile: .planfile
+ working-directory: "infra/terraform"
- name: Terraform Apply
id: apply
-# if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile
working-directory: "infra/terraform"
diff --git a/apps/demo/package.json b/apps/demo/package.json
index ce27d6244b..1ee31994d2 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -1,6 +1,6 @@
{
"name": "@formbricks/demo",
- "version": "0.1.0",
+ "version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -12,8 +12,8 @@
},
"dependencies": {
"@formbricks/js": "workspace:*",
- "lucide-react": "0.468.0",
- "next": "15.1.2",
+ "lucide-react": "0.486.0",
+ "next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},
diff --git a/apps/storybook/package.json b/apps/storybook/package.json
index 5ffdc138d3..68db164108 100644
--- a/apps/storybook/package.json
+++ b/apps/storybook/package.json
@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
- "eslint-plugin-react-refresh": "0.4.16",
- "react": "19.0.0",
- "react-dom": "19.0.0"
+ "eslint-plugin-react-refresh": "0.4.19",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
},
"devDependencies": {
- "@chromatic-com/storybook": "3.2.2",
+ "@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*",
- "@storybook/addon-a11y": "8.4.7",
- "@storybook/addon-essentials": "8.4.7",
- "@storybook/addon-interactions": "8.4.7",
- "@storybook/addon-links": "8.4.7",
- "@storybook/addon-onboarding": "8.4.7",
- "@storybook/blocks": "8.4.7",
- "@storybook/react": "8.4.7",
- "@storybook/react-vite": "8.4.7",
- "@storybook/test": "8.4.7",
- "@typescript-eslint/eslint-plugin": "8.18.0",
- "@typescript-eslint/parser": "8.18.0",
+ "@storybook/addon-a11y": "8.6.11",
+ "@storybook/addon-essentials": "8.6.11",
+ "@storybook/addon-interactions": "8.6.11",
+ "@storybook/addon-links": "8.6.11",
+ "@storybook/addon-onboarding": "8.6.11",
+ "@storybook/blocks": "8.6.11",
+ "@storybook/react": "8.6.11",
+ "@storybook/react-vite": "8.6.11",
+ "@storybook/test": "8.6.11",
+ "@typescript-eslint/eslint-plugin": "8.29.0",
+ "@typescript-eslint/parser": "8.29.0",
"@vitejs/plugin-react": "4.3.4",
- "esbuild": "0.25.1",
- "eslint-plugin-storybook": "0.11.1",
+ "esbuild": "0.25.2",
+ "eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
- "storybook": "8.4.7",
- "tsup": "8.3.5",
- "vite": "6.0.9"
+ "storybook": "8.6.11",
+ "tsup": "8.4.0",
+ "vite": "6.2.4"
}
}
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index b16f185c9f..3e8039e5f2 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -24,17 +24,27 @@ RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
-# Set hardcoded environment variables
-ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
-ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
-ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
-ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime"
+# BuildKit secret handling without hardcoded fallback values
+# This approach relies entirely on secrets passed from GitHub Actions
+RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
+ echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
+ echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
+ echo 'else' >> /tmp/read-secrets.sh && \
+ echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
+ echo 'fi' >> /tmp/read-secrets.sh && \
+ echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
+ echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
+ echo 'else' >> /tmp/read-secrets.sh && \
+ echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
+ echo 'fi' >> /tmp/read-secrets.sh && \
+ echo 'exec "$@"' >> /tmp/read-secrets.sh && \
+ chmod +x /tmp/read-secrets.sh
-ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
-# Increase Node.js memory limit
-# ENV NODE_OPTIONS="--max_old_space_size=4096"
+# Increase Node.js memory limit as a regular build argument
+ARG NODE_OPTIONS="--max_old_space_size=4096"
+ENV NODE_OPTIONS=${NODE_OPTIONS}
# Set the working directory
WORKDIR /app
@@ -53,8 +63,11 @@ RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install
-# Build the project
-RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
+# Build the project using our secret reader script
+# This mounts the secrets only during this build step without storing them in layers
+RUN --mount=type=secret,id=database_url \
+ --mount=type=secret,id=encryption_key \
+ /tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
@@ -85,6 +98,8 @@ COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
+COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
+COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
@@ -93,14 +108,16 @@ COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_mod
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs
-# Copy only @paralleldrive/cuid2 and @noble/hashes
+# Copy required dependencies
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
+COPY --from=installer /app/node_modules/zod ./node_modules/zod
-RUN npm install -g tsx typescript prisma
+RUN npm install -g tsx typescript prisma pino-pretty
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
+ENV NODE_ENV="production"
# USER nextjs
# Prepare volume for uploads
@@ -119,4 +136,4 @@ CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
- exec node apps/web/server.js
+ exec node apps/web/server.js
\ No newline at end of file
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx
new file mode 100644
index 0000000000..d26c801406
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx
@@ -0,0 +1,103 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import toast from "react-hot-toast";
+import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
+import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
+
+// Mock react-hot-toast so we can assert that a success message is shown
+vi.mock("react-hot-toast", () => ({
+ __esModule: true,
+ default: {
+ success: vi.fn(),
+ },
+}));
+
+// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy.
+beforeAll(() => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ writable: true,
+ value: {
+ // Using a mockResolvedValue resolves the promise as writeText is async.
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
+});
+
+describe("OnboardingSetupInstructions", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ // Provide some default props for testing
+ const defaultProps = {
+ environmentId: "env-123",
+ webAppUrl: "https://example.com",
+ channel: "app" as const, // Assuming channel is either "app" or "website"
+ widgetSetupCompleted: false,
+ };
+
+ test("renders HTML tab content by default", () => {
+ render( );
+
+ // Since the default active tab is "html", we check for a unique text
+ expect(
+ screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i)
+ ).toBeInTheDocument();
+
+ // The HTML snippet contains a marker comment
+ expect(screen.getByText("START")).toBeInTheDocument();
+
+ // Verify the "Copy Code" button is present
+ expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument();
+ });
+
+ test("renders NPM tab content when selected", async () => {
+ render( );
+ const user = userEvent.setup();
+
+ // Click on the "NPM" tab to switch views.
+ const npmTab = screen.getByText("NPM");
+ await user.click(npmTab);
+
+ // Check that the install commands are present
+ expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument();
+ expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument();
+
+ // Verify the "Read Docs" link has the correct URL (based on channel prop)
+ const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i });
+ expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides");
+ });
+
+ test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => {
+ render( );
+ const user = userEvent.setup();
+
+ const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
+
+ // Click the "Copy Code" button
+ const copyButton = screen.getByRole("button", { name: /common.copy_code/i });
+ await user.click(copyButton);
+
+ // Ensure navigator.clipboard.writeText was called.
+ expect(writeTextSpy).toHaveBeenCalled();
+ const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string;
+
+ // Check that the pasted snippet contains the expected environment values
+ expect(writtenText).toContain('var appUrl = "https://example.com"');
+ expect(writtenText).toContain('var environmentId = "env-123"');
+
+ // Verify that a success toast was shown
+ expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
+ });
+
+ test("renders step-by-step manual link with correct URL in HTML tab", () => {
+ render( );
+ const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i });
+ expect(manualLink).toHaveAttribute(
+ "href",
+ "https://formbricks.com/docs/app-surveys/framework-guides#html"
+ );
+ });
+});
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx
index 33822ca1c9..7ceb44322a 100644
--- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx
@@ -36,7 +36,7 @@ export const OnboardingSetupInstructions = ({
!function(){
var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}";
- var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl})},500)}();
+ var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
`;
@@ -46,7 +46,7 @@ export const OnboardingSetupInstructions = ({
!function(){
var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}";
- var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl })},500)}();
+ var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
`;
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts
index c6f5e0c82b..fa11c26f11 100644
--- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts
@@ -1,13 +1,10 @@
import { getDefaultEndingCard } from "@/app/lib/templates";
import { createId } from "@paralleldrive/cuid2";
import { TFnType } from "@tolgee/react";
+import { logger } from "@formbricks/logger";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
-function logError(error: Error, context: string) {
- console.error(`Error in ${context}:`, error);
-}
-
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
try {
return {
@@ -19,7 +16,7 @@ export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
},
};
} catch (error) {
- logError(error, "getXMSurveyDefault");
+ logger.error(error, "Failed to create default XM survey template");
throw error; // Re-throw after logging
}
};
@@ -449,7 +446,7 @@ export const getXMTemplates = (t: TFnType): TXMTemplate[] => {
enpsSurvey(t),
];
} catch (error) {
- logError(error, "getXMTemplates");
+ logger.error(error, "Unable to load XM templates, returning empty array");
return []; // Return an empty array or handle as needed
}
};
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx
index 5dea2c22d6..5bc2b635e7 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx
@@ -1,27 +1,25 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
-import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
+import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
-import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
-import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service";
+import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
- const session = await getServerSession(authOptions);
- if (!session || !session.user) {
+
+ const { session, organization } = await getOrganizationAuth(params.organizationId);
+
+ if (!session?.user) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) return notFound();
- const organization = await getOrganization(params.organizationId);
- if (!organization) return notFound();
-
const organizations = await getOrganizationsByUserId(session.user.id);
const { features } = await getEnterpriseLicense();
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx
new file mode 100644
index 0000000000..850229ddca
--- /dev/null
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx
@@ -0,0 +1,156 @@
+import "@testing-library/jest-dom/vitest";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { getServerSession } from "next-auth";
+import { redirect } from "next/navigation";
+import React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
+import { getOrganization } from "@formbricks/lib/organization/service";
+import { getUser } from "@formbricks/lib/user/service";
+import { TOrganization } from "@formbricks/types/organizations";
+import { TUser } from "@formbricks/types/user";
+import ProjectOnboardingLayout from "./layout";
+
+// Mock all the modules and functions that this layout uses:
+
+vi.mock("@formbricks/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+ POSTHOG_API_KEY: "mock-posthog-api-key",
+ POSTHOG_HOST: "mock-posthog-host",
+ IS_POSTHOG_CONFIGURED: true,
+ ENCRYPTION_KEY: "mock-encryption-key",
+ ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
+ GITHUB_ID: "mock-github-id",
+ GITHUB_SECRET: "test-githubID",
+ GOOGLE_CLIENT_ID: "test-google-client-id",
+ GOOGLE_CLIENT_SECRET: "test-google-client-secret",
+ AZUREAD_CLIENT_ID: "test-azuread-client-id",
+ AZUREAD_CLIENT_SECRET: "test-azure",
+ AZUREAD_TENANT_ID: "test-azuread-tenant-id",
+ OIDC_DISPLAY_NAME: "test-oidc-display-name",
+ OIDC_CLIENT_ID: "test-oidc-client-id",
+ OIDC_ISSUER: "test-oidc-issuer",
+ OIDC_CLIENT_SECRET: "test-oidc-client-secret",
+ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
+ WEBAPP_URL: "test-webapp-url",
+ IS_PRODUCTION: false,
+}));
+
+vi.mock("next-auth", () => ({
+ getServerSession: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({
+ redirect: vi.fn(),
+}));
+vi.mock("@formbricks/lib/organization/auth", () => ({
+ canUserAccessOrganization: vi.fn(),
+}));
+vi.mock("@formbricks/lib/organization/service", () => ({
+ getOrganization: vi.fn(),
+}));
+vi.mock("@formbricks/lib/user/service", () => ({
+ getUser: vi.fn(),
+}));
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: vi.fn(() => {
+ // Return a mock translator that just returns the key
+ return (key: string) => key;
+ }),
+}));
+
+// mock the child components
+vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
+ PosthogIdentify: () =>
,
+}));
+vi.mock("@/modules/ui/components/toaster-client", () => ({
+ ToasterClient: () =>
,
+}));
+
+describe("ProjectOnboardingLayout", () => {
+ beforeEach(() => {
+ cleanup();
+ });
+
+ it("redirects to /auth/login if there is no session", async () => {
+ // Mock no session
+ vi.mocked(getServerSession).mockResolvedValueOnce(null);
+
+ const layoutElement = await ProjectOnboardingLayout({
+ params: { organizationId: "org-123" },
+ children: Hello!
,
+ });
+
+ expect(redirect).toHaveBeenCalledWith("/auth/login");
+ // Layout returns nothing after redirect
+ expect(layoutElement).toBeUndefined();
+ });
+
+ it("throws an error if user does not exist", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({
+ user: { id: "user-123" },
+ });
+ vi.mocked(getUser).mockResolvedValueOnce(null); // no user in DB
+
+ await expect(
+ ProjectOnboardingLayout({
+ params: { organizationId: "org-123" },
+ children: Hello!
,
+ })
+ ).rejects.toThrow("common.user_not_found");
+ });
+
+ it("throws AuthorizationError if user cannot access organization", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
+ vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
+
+ await expect(
+ ProjectOnboardingLayout({
+ params: { organizationId: "org-123" },
+ children: Child
,
+ })
+ ).rejects.toThrow("common.not_authorized");
+ });
+
+ it("throws an error if organization does not exist", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
+ vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
+ vi.mocked(getOrganization).mockResolvedValueOnce(null);
+
+ await expect(
+ ProjectOnboardingLayout({
+ params: { organizationId: "org-123" },
+ children: Hello!
,
+ })
+ ).rejects.toThrow("common.organization_not_found");
+ });
+
+ it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
+ // Provide valid data
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);
+ vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
+ vi.mocked(getOrganization).mockResolvedValueOnce({
+ id: "org-123",
+ name: "Test Org",
+ billing: {
+ plan: "enterprise",
+ },
+ } as TOrganization);
+
+ let layoutElement: React.ReactNode;
+ // Because it's an async server component, do it in an act
+ await act(async () => {
+ layoutElement = await ProjectOnboardingLayout({
+ params: { organizationId: "org-123" },
+ children: Hello!
,
+ });
+ render(layoutElement);
+ });
+
+ expect(screen.getByTestId("child-content")).toHaveTextContent("Hello!");
+ expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
+ expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx
index bd5130ad43..6c16a24140 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx
@@ -4,6 +4,7 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
+import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
@@ -16,7 +17,8 @@ const ProjectOnboardingLayout = async (props) => {
const t = await getTranslate();
const session = await getServerSession(authOptions);
- if (!session || !session.user) {
+
+ if (!session?.user) {
return redirect(`/auth/login`);
}
@@ -26,8 +28,9 @@ const ProjectOnboardingLayout = async (props) => {
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
+
if (!isAuthorized) {
- throw AuthorizationError;
+ throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganization(params.organizationId);
@@ -43,6 +46,7 @@ const ProjectOnboardingLayout = async (props) => {
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
+ isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
{children}
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx
index 4ac6aa42bb..4309addd10 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx
@@ -1,10 +1,9 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
-import { authOptions } from "@/modules/auth/lib/authOptions";
+import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
-import { getServerSession } from "next-auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service";
@@ -17,8 +16,10 @@ interface ChannelPageProps {
const Page = async (props: ChannelPageProps) => {
const params = await props.params;
- const session = await getServerSession(authOptions);
- if (!session || !session.user) {
+
+ const { session } = await getOrganizationAuth(params.organizationId);
+
+ if (!session?.user) {
return redirect(`/auth/login`);
}
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx
index c3574c0a9c..a570a6ed89 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx
@@ -1,10 +1,9 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
-import { authOptions } from "@/modules/auth/lib/authOptions";
+import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
-import { getServerSession } from "next-auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service";
@@ -17,8 +16,10 @@ interface ModePageProps {
const Page = async (props: ModePageProps) => {
const params = await props.params;
- const session = await getServerSession(authOptions);
- if (!session || !session.user) {
+
+ const { session } = await getOrganizationAuth(params.organizationId);
+
+ if (!session?.user) {
return redirect(`/auth/login`);
}
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx
index 9c7d4f856c..5a6098b3d3 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx
@@ -1,16 +1,14 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
-import { authOptions } from "@/modules/auth/lib/authOptions";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
+import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react";
-import { getServerSession } from "next-auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
-import { getOrganization } from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
@@ -29,25 +27,20 @@ const Page = async (props: ProjectSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
- const session = await getServerSession(authOptions);
- if (!session || !session.user) {
+ const { session, organization } = await getOrganizationAuth(params.organizationId);
+
+ if (!session?.user) {
return redirect(`/auth/login`);
}
- const channel = searchParams.channel || null;
- const industry = searchParams.industry || null;
- const mode = searchParams.mode || "surveys";
+ const channel = searchParams.channel ?? null;
+ const industry = searchParams.industry ?? null;
+ const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
- const organization = await getOrganization(params.organizationId);
-
- if (!organization) {
- throw new Error(t("common.organization_not_found"));
- }
-
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!organizationTeams) {
diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx
new file mode 100644
index 0000000000..f3de86d04f
--- /dev/null
+++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx
@@ -0,0 +1,191 @@
+import "@testing-library/jest-dom/vitest";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { getServerSession } from "next-auth";
+import { redirect } from "next/navigation";
+import React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
+import { getEnvironment } from "@formbricks/lib/environment/service";
+import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
+import { getUser } from "@formbricks/lib/user/service";
+import { TEnvironment } from "@formbricks/types/environment";
+import { AuthorizationError } from "@formbricks/types/errors";
+import { TOrganization } from "@formbricks/types/organizations";
+import { TUser } from "@formbricks/types/user";
+import SurveyEditorEnvironmentLayout from "./layout";
+
+// mock all dependencies
+
+vi.mock("@formbricks/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+ POSTHOG_API_KEY: "mock-posthog-api-key",
+ POSTHOG_HOST: "mock-posthog-host",
+ IS_POSTHOG_CONFIGURED: true,
+ ENCRYPTION_KEY: "mock-encryption-key",
+ ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
+ GITHUB_ID: "mock-github-id",
+ GITHUB_SECRET: "test-githubID",
+ GOOGLE_CLIENT_ID: "test-google-client-id",
+ GOOGLE_CLIENT_SECRET: "test-google-client-secret",
+ AZUREAD_CLIENT_ID: "test-azuread-client-id",
+ AZUREAD_CLIENT_SECRET: "test-azure",
+ AZUREAD_TENANT_ID: "test-azuread-tenant-id",
+ OIDC_DISPLAY_NAME: "test-oidc-display-name",
+ OIDC_CLIENT_ID: "test-oidc-client-id",
+ OIDC_ISSUER: "test-oidc-issuer",
+ OIDC_CLIENT_SECRET: "test-oidc-client-secret",
+ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
+ WEBAPP_URL: "test-webapp-url",
+ IS_PRODUCTION: false,
+}));
+
+vi.mock("next-auth", () => ({
+ getServerSession: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({
+ redirect: vi.fn(),
+}));
+vi.mock("@formbricks/lib/environment/auth", () => ({
+ hasUserEnvironmentAccess: vi.fn(),
+}));
+vi.mock("@formbricks/lib/environment/service", () => ({
+ getEnvironment: vi.fn(),
+}));
+vi.mock("@formbricks/lib/organization/service", () => ({
+ getOrganizationByEnvironmentId: vi.fn(),
+}));
+vi.mock("@formbricks/lib/user/service", () => ({
+ getUser: vi.fn(),
+}));
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: vi.fn(() => {
+ return (key: string) => key; // trivial translator returning the key
+ }),
+}));
+
+// mock child components rendered by the layout:
+vi.mock("@/app/(app)/components/FormbricksClient", () => ({
+ FormbricksClient: () =>
,
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
+ PosthogIdentify: () =>
,
+}));
+vi.mock("@/modules/ui/components/toaster-client", () => ({
+ ToasterClient: () =>
,
+}));
+vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
+ DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
+ {environment?.id || "no-env"}
+ ),
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
+ ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+describe("SurveyEditorEnvironmentLayout", () => {
+ beforeEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("redirects to /auth/login if there is no session", async () => {
+ // Mock no session
+ vi.mocked(getServerSession).mockResolvedValueOnce(null);
+
+ const layoutElement = await SurveyEditorEnvironmentLayout({
+ params: { environmentId: "env-123" },
+ children: Hello!
,
+ });
+
+ expect(redirect).toHaveBeenCalledWith("/auth/login");
+ // No JSX is returned after redirect
+ expect(layoutElement).toBeUndefined();
+ });
+
+ it("throws error if user does not exist in DB", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
+
+ await expect(
+ SurveyEditorEnvironmentLayout({
+ params: { environmentId: "env-123" },
+ children: Hello!
,
+ })
+ ).rejects.toThrow("common.user_not_found");
+ });
+
+ it("throws AuthorizationError if user does not have environment access", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
+
+ await expect(
+ SurveyEditorEnvironmentLayout({
+ params: { environmentId: "env-123" },
+ children: Child
,
+ })
+ ).rejects.toThrow(AuthorizationError);
+ });
+
+ it("throws if no organization is found", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
+
+ await expect(
+ SurveyEditorEnvironmentLayout({
+ params: { environmentId: "env-123" },
+ children: Hello from children!
,
+ })
+ ).rejects.toThrow("common.organization_not_found");
+ });
+
+ it("throws if no environment is found", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
+ vi.mocked(getEnvironment).mockResolvedValueOnce(null);
+
+ await expect(
+ SurveyEditorEnvironmentLayout({
+ params: { environmentId: "env-123" },
+ children: Child
,
+ })
+ ).rejects.toThrow("common.environment_not_found");
+ });
+
+ it("renders environment layout if everything is valid", async () => {
+ // Provide all valid data
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
+ vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
+ vi.mocked(getEnvironment).mockResolvedValueOnce({
+ id: "env-123",
+ name: "My Test Environment",
+ } as unknown as TEnvironment);
+
+ // Because it's an async server component, we typically wrap in act(...)
+ let layoutElement: React.ReactNode;
+
+ await act(async () => {
+ layoutElement = await SurveyEditorEnvironmentLayout({
+ params: { environmentId: "env-123" },
+ children: Hello from children!
,
+ });
+ render(layoutElement);
+ });
+
+ // Now confirm we got the child plus all the mocked sub-components
+ expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
+ expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
+ expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
+ expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
+ expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
+ expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
+ });
+});
diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx
index 7b1470c669..f8c34c8dd3 100644
--- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx
+++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx
@@ -7,6 +7,7 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
+import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -20,7 +21,8 @@ const SurveyEditorEnvironmentLayout = async (props) => {
const t = await getTranslate();
const session = await getServerSession(authOptions);
- if (!session || !session.user) {
+
+ if (!session?.user) {
return redirect(`/auth/login`);
}
@@ -46,24 +48,23 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
- <>
-
-
-
-
-
-
- >
+
+
+
+
+
+
);
};
diff --git a/apps/web/app/(app)/components/FormbricksClient.test.tsx b/apps/web/app/(app)/components/FormbricksClient.test.tsx
new file mode 100644
index 0000000000..a0e0b986ca
--- /dev/null
+++ b/apps/web/app/(app)/components/FormbricksClient.test.tsx
@@ -0,0 +1,77 @@
+import { render } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import formbricks from "@formbricks/js";
+import { FormbricksClient } from "./FormbricksClient";
+
+// Mock next/navigation hooks.
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/test-path",
+ useSearchParams: () => new URLSearchParams("foo=bar"),
+}));
+
+// Mock the environment variables.
+vi.mock("@formbricks/lib/env", () => ({
+ env: {
+ NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
+ NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
+ },
+}));
+
+// Mock the flag that enables Formbricks.
+vi.mock("@/app/lib/formbricks", () => ({
+ formbricksEnabled: true,
+}));
+
+// Mock the Formbricks SDK module.
+vi.mock("@formbricks/js", () => ({
+ __esModule: true,
+ default: {
+ setup: vi.fn(),
+ setUserId: vi.fn(),
+ setEmail: vi.fn(),
+ registerRouteChange: vi.fn(),
+ },
+}));
+
+describe("FormbricksClient", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
+ const mockSetup = vi.spyOn(formbricks, "setup");
+ const mockSetUserId = vi.spyOn(formbricks, "setUserId");
+ const mockSetEmail = vi.spyOn(formbricks, "setEmail");
+ const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
+
+ render( );
+
+ // Expect the first effect to call setup and assign the provided user details.
+ expect(mockSetup).toHaveBeenCalledWith({
+ environmentId: "env-test",
+ appUrl: "https://api.test.com",
+ });
+ expect(mockSetUserId).toHaveBeenCalledWith("user-123");
+ expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
+
+ // And the second effect should always register the route change when Formbricks is enabled.
+ expect(mockRegisterRouteChange).toHaveBeenCalled();
+ });
+
+ test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
+ const mockSetup = vi.spyOn(formbricks, "setup");
+ const mockSetUserId = vi.spyOn(formbricks, "setUserId");
+ const mockSetEmail = vi.spyOn(formbricks, "setEmail");
+ const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
+
+ render( );
+
+ // Since userId is falsy, the first effect should not call setup or assign user details.
+ expect(mockSetup).not.toHaveBeenCalled();
+ expect(mockSetUserId).not.toHaveBeenCalled();
+ expect(mockSetEmail).not.toHaveBeenCalled();
+
+ // The second effect only checks formbricksEnabled, so registerRouteChange should be called.
+ expect(mockRegisterRouteChange).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx
new file mode 100644
index 0000000000..90eb344a89
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx
@@ -0,0 +1,153 @@
+// PosthogIdentify.test.tsx
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render } from "@testing-library/react";
+import { Session } from "next-auth";
+import { usePostHog } from "posthog-js/react";
+import React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { TOrganizationBilling } from "@formbricks/types/organizations";
+import { TUser } from "@formbricks/types/user";
+import { PosthogIdentify } from "./PosthogIdentify";
+
+type PartialPostHog = Partial>;
+
+vi.mock("posthog-js/react", () => ({
+ usePostHog: vi.fn(),
+}));
+
+describe("PosthogIdentify", () => {
+ beforeEach(() => {
+ cleanup();
+ });
+
+ it("identifies the user and sets groups when isPosthogEnabled is true", () => {
+ const mockIdentify = vi.fn();
+ const mockGroup = vi.fn();
+
+ const mockPostHog: PartialPostHog = {
+ identify: mockIdentify,
+ group: mockGroup,
+ };
+
+ vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType);
+
+ render(
+
+ );
+
+ // verify that identify is called with the session user id + extra info
+ expect(mockIdentify).toHaveBeenCalledWith("user-123", {
+ name: "Test User",
+ email: "test@example.com",
+ role: "engineer",
+ objective: "increase_conversion",
+ });
+
+ // environment + organization groups
+ expect(mockGroup).toHaveBeenCalledTimes(2);
+ expect(mockGroup).toHaveBeenCalledWith("environment", "env-456", { name: "env-456" });
+ expect(mockGroup).toHaveBeenCalledWith("organization", "org-789", {
+ name: "Test Org",
+ plan: "enterprise",
+ responseLimit: 1000,
+ miuLimit: 5000,
+ });
+ });
+
+ it("does nothing if isPosthogEnabled is false", () => {
+ const mockIdentify = vi.fn();
+ const mockGroup = vi.fn();
+
+ const mockPostHog: PartialPostHog = {
+ identify: mockIdentify,
+ group: mockGroup,
+ };
+
+ vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType);
+
+ render(
+
+ );
+
+ expect(mockIdentify).not.toHaveBeenCalled();
+ expect(mockGroup).not.toHaveBeenCalled();
+ });
+
+ it("does nothing if session user is missing", () => {
+ const mockIdentify = vi.fn();
+ const mockGroup = vi.fn();
+
+ const mockPostHog: PartialPostHog = {
+ identify: mockIdentify,
+ group: mockGroup,
+ };
+
+ vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType);
+
+ render(
+
+ );
+
+ // Because there's no session.user, we skip identify
+ expect(mockIdentify).not.toHaveBeenCalled();
+ expect(mockGroup).not.toHaveBeenCalled();
+ });
+
+ it("identifies user but does not group if environmentId/organizationId not provided", () => {
+ const mockIdentify = vi.fn();
+ const mockGroup = vi.fn();
+
+ const mockPostHog: PartialPostHog = {
+ identify: mockIdentify,
+ group: mockGroup,
+ };
+
+ vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType);
+
+ render(
+
+ );
+
+ expect(mockIdentify).toHaveBeenCalledWith("user-123", {
+ name: "Test User",
+ email: "test@example.com",
+ role: undefined,
+ objective: undefined,
+ });
+ // No environmentId or organizationId => no group calls
+ expect(mockGroup).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx
index 91ed3d2f21..9bb42a3338 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx
@@ -3,12 +3,9 @@
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
-import { env } from "@formbricks/lib/env";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
-const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
-
interface PosthogIdentifyProps {
session: Session;
user: TUser;
@@ -16,6 +13,7 @@ interface PosthogIdentifyProps {
organizationId?: string;
organizationName?: string;
organizationBilling?: TOrganizationBilling;
+ isPosthogEnabled: boolean;
}
export const PosthogIdentify = ({
@@ -25,11 +23,12 @@ export const PosthogIdentify = ({
organizationId,
organizationName,
organizationBilling,
+ isPosthogEnabled,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
useEffect(() => {
- if (posthogEnabled && session.user && posthog) {
+ if (isPosthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, {
name: user.name,
email: user.email,
@@ -59,6 +58,7 @@ export const PosthogIdentify = ({
user.email,
user.role,
user.objective,
+ isPosthogEnabled,
]);
return null;
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx
index d485b222a2..ec62057383 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx
@@ -6,7 +6,7 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getTodayDate } from "@/app/lib/surveys/surveys";
-import { createContext, useCallback, useContext, useState } from "react";
+import React, { createContext, useCallback, useContext, useState } from "react";
export interface FilterValue {
questionType: Partial;
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx
index eea9218900..205de99e2d 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx
@@ -1,6 +1,5 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
-import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -24,7 +23,6 @@ export const TopControlBar = ({
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx
index 2646546db3..22ef9d8218 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx
@@ -6,9 +6,9 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
-import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react";
+import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
-import formbricks from "@formbricks/js";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -16,7 +16,6 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface TopControlButtonsProps {
environment: TEnvironment;
environments: TEnvironment[];
- isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null;
}
@@ -24,7 +23,6 @@ interface TopControlButtonsProps {
export const TopControlButtons = ({
environment,
environments,
- isFormbricksCloud,
membershipRole,
projectPermission,
}: TopControlButtonsProps) => {
@@ -38,19 +36,15 @@ export const TopControlButtons = ({
return (
{!isBilling &&
}
- {isFormbricksCloud && (
-
- {
- formbricks.track("Top Menu: Product Feedback");
- }}>
-
-
-
- )}
+
+
+
+
+
+
+
+
+
{
@@ -17,7 +18,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
});
if (!res.ok) {
- console.error(res.text);
+ const errorText = await res.text();
+ logger.error({ errorText }, "authorize: Could not fetch airtable config");
throw new Error("Could not create response");
}
const resJSON = await res.json();
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google.ts
index dd7d6b03b4..267d4fed7a 100644
--- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google.ts
@@ -1,3 +1,5 @@
+import { logger } from "@formbricks/logger";
+
export const authorize = async (environmentId: string, apiHost: string): Promise => {
const res = await fetch(`${apiHost}/api/google-sheet`, {
method: "GET",
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
});
if (!res.ok) {
- console.error(res.text);
+ const errorText = await res.text();
+ logger.error({ errorText }, "authorize: Could not fetch google sheet config");
throw new Error("Could not create response");
}
const resJSON = await res.json();
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts
index 3039099837..7e9127267a 100644
--- a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts
@@ -7,6 +7,7 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
+import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -34,7 +35,7 @@ export const getSurveys = reactCache(
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
- console.error(error);
+ logger.error({ error }, "getSurveys: Could not fetch surveys");
throw new DatabaseError(error.message);
}
throw error;
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.ts
index 5eab694413..2aaec82d5e 100644
--- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.ts
@@ -1,3 +1,5 @@
+import { logger } from "@formbricks/logger";
+
export const authorize = async (environmentId: string, apiHost: string): Promise => {
const res = await fetch(`${apiHost}/api/v1/integrations/notion`, {
method: "GET",
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
});
if (!res.ok) {
- console.error(res.text);
+ const errorText = await res.text();
+ logger.error({ errorText }, "authorize: Could not fetch notion config");
throw new Error("Could not create response");
}
const resJSON = await res.json();
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/lib/slack.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/lib/slack.ts
index 252bd36bac..74f7edb2f3 100644
--- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/lib/slack.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/lib/slack.ts
@@ -1,3 +1,5 @@
+import { logger } from "@formbricks/logger";
+
export const authorize = async (environmentId: string, apiHost: string): Promise => {
const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
method: "GET",
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
});
if (!res.ok) {
- console.error(res.text);
+ const errorText = await res.text();
+ logger.error({ errorText }, "authorize: Could not fetch slack config");
throw new Error("Could not create response");
}
const resJSON = await res.json();
diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.test.tsx
new file mode 100644
index 0000000000..bec2826e02
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/layout.test.tsx
@@ -0,0 +1,250 @@
+import "@testing-library/jest-dom/vitest";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { getServerSession } from "next-auth";
+import { notFound, redirect } from "next/navigation";
+import React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
+import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
+import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
+import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
+import { getUser } from "@formbricks/lib/user/service";
+import { AuthorizationError } from "@formbricks/types/errors";
+import { TMembership } from "@formbricks/types/memberships";
+import { TOrganization } from "@formbricks/types/organizations";
+import { TProject } from "@formbricks/types/project";
+import { TUser } from "@formbricks/types/user";
+import EnvLayout from "./layout";
+
+// mock all the dependencies
+
+vi.mock("@formbricks/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+ POSTHOG_API_KEY: "mock-posthog-api-key",
+ POSTHOG_HOST: "mock-posthog-host",
+ IS_POSTHOG_CONFIGURED: true,
+ ENCRYPTION_KEY: "mock-encryption-key",
+ ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
+ GITHUB_ID: "mock-github-id",
+ GITHUB_SECRET: "test-githubID",
+ GOOGLE_CLIENT_ID: "test-google-client-id",
+ GOOGLE_CLIENT_SECRET: "test-google-client-secret",
+ AZUREAD_CLIENT_ID: "test-azuread-client-id",
+ AZUREAD_CLIENT_SECRET: "test-azure",
+ AZUREAD_TENANT_ID: "test-azuread-tenant-id",
+ OIDC_DISPLAY_NAME: "test-oidc-display-name",
+ OIDC_CLIENT_ID: "test-oidc-client-id",
+ OIDC_ISSUER: "test-oidc-issuer",
+ OIDC_CLIENT_SECRET: "test-oidc-client-secret",
+ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
+ WEBAPP_URL: "test-webapp-url",
+ IS_PRODUCTION: false,
+}));
+
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: vi.fn(() => {
+ return (key: string) => {
+ return key;
+ };
+ }),
+}));
+
+vi.mock("next-auth", () => ({
+ getServerSession: vi.fn(),
+}));
+vi.mock("@formbricks/lib/environment/auth", () => ({
+ hasUserEnvironmentAccess: vi.fn(),
+}));
+vi.mock("@formbricks/lib/membership/service", () => ({
+ getMembershipByUserIdOrganizationId: vi.fn(),
+}));
+vi.mock("@formbricks/lib/organization/service", () => ({
+ getOrganizationByEnvironmentId: vi.fn(),
+}));
+vi.mock("@formbricks/lib/project/service", () => ({
+ getProjectByEnvironmentId: vi.fn(),
+}));
+vi.mock("@formbricks/lib/user/service", () => ({
+ getUser: vi.fn(),
+}));
+vi.mock("@formbricks/lib/aiModels", () => ({
+ llmModel: {},
+}));
+
+// mock all the components that are rendered in the layout
+
+vi.mock("./components/PosthogIdentify", () => ({
+ PosthogIdentify: () =>
,
+}));
+vi.mock("@/app/(app)/components/FormbricksClient", () => ({
+ FormbricksClient: () =>
,
+}));
+vi.mock("@/modules/ui/components/toaster-client", () => ({
+ ToasterClient: () =>
,
+}));
+vi.mock("./components/EnvironmentStorageHandler", () => ({
+ default: () =>
,
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
+ ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
+ EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+describe("EnvLayout", () => {
+ beforeEach(() => {
+ cleanup();
+ });
+
+ it("redirects to /auth/login if there is no session", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce(null);
+
+ // Since it's an async server component, call EnvLayout yourself:
+ const layoutElement = await EnvLayout({
+ params: Promise.resolve({ environmentId: "env-123" }),
+ children: Hello!
,
+ });
+
+ // Because we have no session, we expect a redirect to "/auth/login"
+ expect(redirect).toHaveBeenCalledWith("/auth/login");
+
+ // If your code calls redirect() early and returns no JSX,
+ // layoutElement might be undefined or null.
+ expect(layoutElement).toBeUndefined();
+ });
+
+ it("redirects to /auth/login if user does not exist in DB", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({
+ user: { id: "user-123" },
+ });
+
+ vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
+
+ const layoutElement = await EnvLayout({
+ params: Promise.resolve({ environmentId: "env-123" }),
+ children: Hello!
,
+ });
+
+ expect(redirect).toHaveBeenCalledWith("/auth/login");
+ expect(layoutElement).toBeUndefined();
+ });
+
+ it("throws AuthorizationError if user does not have environment access", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({
+ user: { id: "user-123" },
+ });
+ vi.mocked(getUser).mockResolvedValueOnce({
+ id: "user-123",
+ email: "test@example.com",
+ } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
+
+ await expect(
+ EnvLayout({
+ params: Promise.resolve({ environmentId: "env-123" }),
+ children: Child
,
+ })
+ ).rejects.toThrow(AuthorizationError);
+ });
+
+ it("throws if no organization is found", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({
+ user: { id: "user-123" },
+ });
+ vi.mocked(getUser).mockResolvedValueOnce({
+ id: "user-123",
+ email: "test@example.com",
+ } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
+
+ await expect(
+ EnvLayout({
+ params: Promise.resolve({ environmentId: "env-123" }),
+ children: Hello from children!
,
+ })
+ ).rejects.toThrow("common.organization_not_found");
+ });
+
+ it("throws if no project is found", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({
+ user: { id: "user-123" },
+ });
+ vi.mocked(getUser).mockResolvedValueOnce({
+ id: "user-123",
+ email: "test@example.com",
+ } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
+ vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
+
+ await expect(
+ EnvLayout({
+ params: Promise.resolve({ environmentId: "env-123" }),
+ children: Child
,
+ })
+ ).rejects.toThrow("project_not_found");
+ });
+
+ it("calls notFound if membership is missing", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({
+ user: { id: "user-123" },
+ });
+ vi.mocked(getUser).mockResolvedValueOnce({
+ id: "user-123",
+ email: "test@example.com",
+ } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
+ vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
+ vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
+
+ await expect(
+ EnvLayout({
+ params: Promise.resolve({ environmentId: "env-123" }),
+ children: Child
,
+ })
+ ).rejects.toThrow("membership_not_found");
+ });
+
+ it("renders environment layout if everything is valid", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({
+ user: { id: "user-123" },
+ });
+ vi.mocked(getUser).mockResolvedValueOnce({
+ id: "user-123",
+ email: "test@example.com",
+ } as TUser);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
+ vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
+ vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
+ id: "membership-123",
+ } as unknown as TMembership);
+
+ let layoutElement: React.ReactNode;
+
+ await act(async () => {
+ layoutElement = await EnvLayout({
+ params: Promise.resolve({ environmentId: "env-123" }),
+ children: Hello from children!
,
+ });
+
+ // Now render the fully resolved layout
+ render(layoutElement);
+ });
+
+ expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
+ expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
+ expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
+ expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
+ expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
+ expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
+ expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx
index 975ff73f96..b1565c49c6 100644
--- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx
@@ -5,6 +5,7 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
+import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -25,7 +26,8 @@ const EnvLayout = async (props: {
const t = await getTranslate();
const session = await getServerSession(authOptions);
- if (!session || !session.user) {
+
+ if (!session?.user) {
return redirect(`/auth/login`);
}
@@ -55,24 +57,23 @@ const EnvLayout = async (props: {
}
return (
- <>
-
-
-
-
-
-
- {children}
-
-
- >
+
+
+
+
+
+
+ {children}
+
+
);
};
diff --git a/apps/web/app/(app)/environments/[environmentId]/page.tsx b/apps/web/app/(app)/environments/[environmentId]/page.tsx
index 96739edc0c..062dfe781e 100644
--- a/apps/web/app/(app)/environments/[environmentId]/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/page.tsx
@@ -1,24 +1,11 @@
-import { authOptions } from "@/modules/auth/lib/authOptions";
-import { getTranslate } from "@/tolgee/server";
-import { getServerSession } from "next-auth";
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
-import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
const EnvironmentPage = async (props) => {
const params = await props.params;
- const session = await getServerSession(authOptions);
- const t = await getTranslate();
- const organization = await getOrganizationByEnvironmentId(params.environmentId);
-
- if (!session) {
- return redirect(`/auth/login`);
- }
-
- if (!organization) {
- throw new Error(t("common.organization_not_found"));
- }
+ const { session, organization } = await getEnvironmentAuth(params.environmentId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx
index b7f6303685..b31e6432d6 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx
@@ -3,15 +3,11 @@ import {
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
+import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server";
-import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
-import { getAccessFlags } from "@formbricks/lib/membership/utils";
-import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
-import { TMembership } from "@formbricks/types/memberships";
-import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
@@ -37,10 +33,12 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
-}));
-
-vi.mock("next-auth", () => ({
- getServerSession: vi.fn(),
+ AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
+ AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
+ AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
+ AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
+ AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
+ AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
}));
vi.mock("@/tolgee/server", () => ({
@@ -51,16 +49,8 @@ vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
-vi.mock("@formbricks/lib/organization/service", () => ({
- getOrganizationByEnvironmentId: vi.fn(),
-}));
-
-vi.mock("@formbricks/lib/membership/service", () => ({
- getMembershipByUserIdOrganizationId: vi.fn(),
-}));
-
-vi.mock("@formbricks/lib/membership/utils", () => ({
- getAccessFlags: vi.fn(),
+vi.mock("@/modules/environments/lib/utils", () => ({
+ getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
@@ -70,26 +60,21 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
}));
describe("Page", () => {
- const mockParams = { environmentId: "test-environment-id" };
- const mockSession = { user: { id: "test-user-id" } };
+ let mockEnvironmentAuth = {
+ session: { user: { id: "test-user-id" } },
+ currentUserMembership: { role: "owner" },
+ organization: { id: "test-organization-id", billing: { plan: "free" } },
+ isOwner: true,
+ isManager: false,
+ } as unknown as TEnvironmentAuth;
+
const mockUser = { id: "test-user-id" } as TUser;
- const mockOrganization = { id: "test-organization-id", billing: { plan: "free" } } as TOrganization;
- const mockMembership = { role: "owner" } as TMembership;
const mockTranslate = vi.fn((key) => key);
beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUser).mockResolvedValue(mockUser);
- vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
- vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
- vi.mocked(getAccessFlags).mockReturnValue({
- isOwner: true,
- isManager: false,
- isBilling: false,
- isMember: false,
- });
+ vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
@@ -105,8 +90,10 @@ describe("Page", () => {
expect(result).toBeTruthy();
});
- it("renders if session user id is null", async () => {
- vi.mocked(getServerSession).mockResolvedValue({ user: { id: null } });
+ it("renders if session user id empty", async () => {
+ mockEnvironmentAuth.session.user.id = "";
+
+ vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
@@ -117,17 +104,13 @@ describe("Page", () => {
expect(result).toBeTruthy();
});
- it("throws an error if the session is not found", async () => {
- vi.mocked(getServerSession).mockResolvedValue(null);
+ it("handles getEnvironmentAuth error", async () => {
+ vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error"));
- await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow("common.session_not_found");
- });
+ const props = {
+ params: Promise.resolve({ environmentId: "env-123" }),
+ };
- it("throws an error if the organization is not found", async () => {
- vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
-
- await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow(
- "common.organization_not_found"
- );
+ await expect(Page(props)).rejects.toThrow("Authentication error");
});
});
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx
index f926cd6580..dfb66fd1f6 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx
@@ -1,21 +1,17 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
-import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
-import { getServerSession } from "next-auth";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
-import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
-import { getAccessFlags } from "@formbricks/lib/membership/utils";
-import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -24,20 +20,13 @@ import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
- const session = await getServerSession(authOptions);
- if (!session) {
- throw new Error(t("common.session_not_found"));
- }
+
+ const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
+ params.environmentId
+ );
+
const user = session?.user?.id ? await getUser(session.user.id) : null;
- const organization = await getOrganizationByEnvironmentId(params.environmentId);
-
- if (!organization) {
- throw new Error(t("common.organization_not_found"));
- }
-
- const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
- const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
@@ -99,7 +88,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
)}
-
+
);
};
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
index d087986bfb..9932a21f60 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
@@ -3,24 +3,17 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
-import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
-import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
-import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
-import { getServerSession } from "next-auth";
import {
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
RESPONSES_PER_PAGE,
WEBAPP_URL,
} from "@formbricks/lib/constants";
-import { getEnvironment } from "@formbricks/lib/environment/service";
-import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
-import { getAccessFlags } from "@formbricks/lib/membership/utils";
-import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
-import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
+import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
@@ -30,53 +23,32 @@ import { findMatchingLocale } from "@formbricks/lib/utils/locale";
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
- const session = await getServerSession(authOptions);
- if (!session) {
- throw new Error(t("common.session_not_found"));
- }
- const [survey, environment] = await Promise.all([
- getSurvey(params.surveyId),
- getEnvironment(params.environmentId),
- ]);
- if (!environment) {
- throw new Error(t("common.environment_not_found"));
- }
+ const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
+
+ const survey = await getSurvey(params.surveyId);
+
if (!survey) {
throw new Error(t("common.survey_not_found"));
}
- const project = await getProjectByEnvironmentId(environment.id);
- if (!project) {
- throw new Error(t("common.project_not_found"));
- }
const user = await getUser(session.user.id);
+
if (!user) {
throw new Error(t("common.user_not_found"));
}
+
const tags = await getTagsByEnvironmentId(params.environmentId);
- const organization = await getOrganizationByEnvironmentId(params.environmentId);
- if (!organization) {
- throw new Error(t("common.organization_not_found"));
- }
-
- const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
- const { isMember } = getAccessFlags(currentUserMembership?.role);
-
- const permission = await getProjectPermissionByUserId(session.user.id, project.id);
- const { hasReadAccess } = getTeamPermissionFlags(permission);
-
- const isReadOnly = isMember && hasReadAccess;
-
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
const locale = await findMatchingLocale();
+ const surveyDomain = getSurveyDomain();
return (
@@ -87,8 +59,8 @@ const Page = async (props) => {
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
- webAppUrl={WEBAPP_URL}
user={user}
+ surveyDomain={surveyDomain}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx
index 6acaa03dfe..7dcfb7bc1b 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx
@@ -23,19 +23,19 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
+ surveyDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch>;
- webAppUrl: string;
user: TUser;
}
export const ShareEmbedSurvey = ({
survey,
+ surveyDomain,
open,
modalView,
setOpen,
- webAppUrl,
user,
}: ShareEmbedSurveyProps) => {
const router = useRouter();
@@ -104,8 +104,8 @@ export const ShareEmbedSurvey = ({
@@ -159,8 +159,8 @@ export const ShareEmbedSurvey = ({
survey={survey}
email={email}
surveyUrl={surveyUrl}
+ surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
- webAppUrl={webAppUrl}
locale={user.locale}
/>
) : showView === "panel" ? (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx
index 1ba4b154c0..69a28d746e 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx
@@ -3,6 +3,8 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
+import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
+import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { Badge } from "@/modules/ui/components/badge";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
@@ -18,8 +20,8 @@ interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
- webAppUrl: string;
user: TUser;
+ surveyDomain: string;
}
interface ModalState {
@@ -33,8 +35,8 @@ export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
- webAppUrl,
user,
+ surveyDomain,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
@@ -48,7 +50,8 @@ export const SurveyAnalysisCTA = ({
dropdown: false,
});
- const surveyUrl = useMemo(() => `${webAppUrl}/s/${survey.id}`, [survey.id, webAppUrl]);
+ const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
+ const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -71,8 +74,11 @@ export const SurveyAnalysisCTA = ({
};
const handleCopyLink = () => {
- navigator.clipboard
- .writeText(surveyUrl)
+ refreshSingleUseId()
+ .then((newId) => {
+ const linkToCopy = copySurveyLink(surveyUrl, newId);
+ return navigator.clipboard.writeText(linkToCopy);
+ })
.then(() => {
toast.success(t("common.copied_to_clipboard"));
})
@@ -166,9 +172,9 @@ export const SurveyAnalysisCTA = ({
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx
index 26a3c9972b..2f75d5237f 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx
@@ -20,8 +20,8 @@ interface EmbedViewProps {
survey: any;
email: string;
surveyUrl: string;
+ surveyDomain: string;
setSurveyUrl: React.Dispatch>;
- webAppUrl: string;
locale: TUserLocale;
}
@@ -35,8 +35,8 @@ export const EmbedView = ({
survey,
email,
surveyUrl,
+ surveyDomain,
setSurveyUrl,
- webAppUrl,
locale,
}: EmbedViewProps) => {
const { t } = useTranslate();
@@ -82,8 +82,8 @@ export const EmbedView = ({
) : activeId === "link" ? (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx
index eee5232091..0c53c04a2f 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx
@@ -8,13 +8,13 @@ import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
- webAppUrl: string;
surveyUrl: string;
+ surveyDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
-export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
+export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
@@ -43,8 +43,8 @@ export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }:
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/tests/SurveyAnalysisCTA.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/tests/SurveyAnalysisCTA.test.tsx
new file mode 100644
index 0000000000..4533f1e897
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/tests/SurveyAnalysisCTA.test.tsx
@@ -0,0 +1,132 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import toast from "react-hot-toast";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TSurvey } from "@formbricks/types/surveys/types";
+import { TUser } from "@formbricks/types/user";
+import { SurveyAnalysisCTA } from "../SurveyAnalysisCTA";
+
+// Mock constants
+vi.mock("@formbricks/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+ ENCRYPTION_KEY: "test",
+ ENTERPRISE_LICENSE_KEY: "test",
+ GITHUB_ID: "test",
+ GITHUB_SECRET: "test",
+ GOOGLE_CLIENT_ID: "test",
+ GOOGLE_CLIENT_SECRET: "test",
+ AZUREAD_CLIENT_ID: "mock-azuread-client-id",
+ AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
+ AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
+ OIDC_CLIENT_ID: "mock-oidc-client-id",
+ OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
+ OIDC_ISSUER: "mock-oidc-issuer",
+ OIDC_DISPLAY_NAME: "mock-oidc-display-name",
+ OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
+ WEBAPP_URL: "mock-webapp-url",
+ AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
+ AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
+ AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
+ AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
+ AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
+ AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
+ IS_PRODUCTION: true,
+ FB_LOGO_URL: "https://example.com/mock-logo.png",
+ SMTP_HOST: "mock-smtp-host",
+ SMTP_PORT: "mock-smtp-port",
+ IS_POSTHOG_CONFIGURED: true,
+}));
+
+// Create a spy for refreshSingleUseId so we can override it in tests
+const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
+
+// Mock useSingleUseId hook
+vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
+ useSingleUseId: () => ({
+ refreshSingleUseId: refreshSingleUseIdSpy,
+ }),
+}));
+
+const mockSearchParams = new URLSearchParams();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ useSearchParams: () => mockSearchParams, // Reuse the same object
+ usePathname: () => "/current",
+}));
+
+// Mock copySurveyLink to return a predictable string
+vi.mock("@/modules/survey/lib/client-utils", () => ({
+ copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
+}));
+
+vi.spyOn(toast, "success");
+vi.spyOn(toast, "error");
+
+// Set up a fake clipboard
+const writeTextMock = vi.fn(() => Promise.resolve());
+Object.assign(navigator, {
+ clipboard: { writeText: writeTextMock },
+});
+
+const dummySurvey = {
+ id: "survey123",
+ type: "link",
+ environmentId: "env123",
+ status: "active",
+} as unknown as TSurvey;
+const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
+const dummyUser = { id: "user123", name: "Test User" } as TUser;
+const surveyDomain = "https://surveys.test.formbricks.com";
+
+describe("SurveyAnalysisCTA - handleCopyLink", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("calls copySurveyLink and clipboard.writeText on success", async () => {
+ render(
+
+ );
+
+ const copyButton = screen.getByRole("button", { name: "common.copy_link" });
+ fireEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(refreshSingleUseIdSpy).toHaveBeenCalled();
+ expect(writeTextMock).toHaveBeenCalledWith(
+ "https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
+ );
+ expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
+ });
+ });
+
+ it("shows error toast on failure", async () => {
+ refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
+ render(
+
+ );
+
+ const copyButton = screen.getByRole("button", { name: "common.copy_link" });
+ fireEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(refreshSingleUseIdSpy).toHaveBeenCalled();
+ expect(writeTextMock).not.toHaveBeenCalled();
+ expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link");
+ });
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx
index f587c16223..9fba9a8ecd 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx
@@ -1,6 +1,6 @@
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { getTranslate } from "@/tolgee/server";
-import { WEBAPP_URL } from "@formbricks/lib/constants";
+import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getStyling } from "@formbricks/lib/utils/styling";
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const styling = getStyling(project, survey);
- const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
+ const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'';
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx
index 6557fe7643..d098eceabf 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx
@@ -3,14 +3,12 @@ import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/s
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
-import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
-import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
-import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
+import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
-import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import {
DEFAULT_LOCALE,
@@ -18,11 +16,7 @@ import {
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
WEBAPP_URL,
} from "@formbricks/lib/constants";
-import { getEnvironment } from "@formbricks/lib/environment/service";
-import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
-import { getAccessFlags } from "@formbricks/lib/membership/utils";
-import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
-import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
+import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getUser } from "@formbricks/lib/user/service";
@@ -30,10 +24,8 @@ import { getUser } from "@formbricks/lib/user/service";
const SurveyPage = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
- const session = await getServerSession(authOptions);
- if (!session) {
- throw new Error(t("common.session_not_found"));
- }
+
+ const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const surveyId = params.surveyId;
@@ -41,41 +33,20 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
return notFound();
}
- const [survey, environment] = await Promise.all([
- getSurvey(params.surveyId),
- getEnvironment(params.environmentId),
- ]);
- if (!environment) {
- throw new Error(t("common.environment_not_found"));
- }
+ const survey = await getSurvey(params.surveyId);
+
if (!survey) {
throw new Error(t("common.survey_not_found"));
}
- const project = await getProjectByEnvironmentId(environment.id);
- if (!project) {
- throw new Error(t("common.project_not_found"));
- }
-
const user = await getUser(session.user.id);
+
if (!user) {
throw new Error(t("common.user_not_found"));
}
- const organization = await getOrganizationByEnvironmentId(params.environmentId);
-
- if (!organization) {
- throw new Error(t("common.organization_not_found"));
- }
- const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
- const { isMember } = getAccessFlags(currentUserMembership?.role);
- const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
- const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
-
- const isReadOnly = isMember && hasReadAccess;
-
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
@@ -84,6 +55,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
billing: organization.billing,
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
+ const surveyDomain = getSurveyDomain();
return (
@@ -94,8 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
- webAppUrl={WEBAPP_URL}
user={user}
+ surveyDomain={surveyDomain}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
@@ -124,6 +96,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}
/>
+
+
);
};
diff --git a/apps/web/app/(app)/layout.test.tsx b/apps/web/app/(app)/layout.test.tsx
index 4ab725fd7c..50f399c095 100644
--- a/apps/web/app/(app)/layout.test.tsx
+++ b/apps/web/app/(app)/layout.test.tsx
@@ -33,6 +33,9 @@ vi.mock("@formbricks/lib/constants", () => ({
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
+ IS_POSTHOG_CONFIGURED: true,
+ POSTHOG_API_HOST: "test-posthog-api-host",
+ POSTHOG_API_KEY: "test-posthog-api-key",
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
@@ -44,12 +47,6 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () =>
,
}));
-vi.mock("@/modules/ui/components/post-hog-client", () => ({
- PHProvider: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
- PostHogPageview: () =>
,
-}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () =>
,
}));
@@ -71,8 +68,6 @@ describe("(app) AppLayout", () => {
render(element);
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
- expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
- expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
index 2368d6d6d1..fc4000225c 100644
--- a/apps/web/app/(app)/layout.tsx
+++ b/apps/web/app/(app)/layout.tsx
@@ -6,6 +6,7 @@ import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-cl
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getServerSession } from "next-auth";
import { Suspense } from "react";
+import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants";
import { getUser } from "@formbricks/lib/user/service";
const AppLayout = async ({ children }) => {
@@ -16,9 +17,13 @@ const AppLayout = async ({ children }) => {
<>
-
+
-
+
<>
{user ? : null}
diff --git a/apps/web/app/[shortUrlId]/page.tsx b/apps/web/app/[shortUrlId]/page.tsx
index eb26877f77..24894cc4ec 100644
--- a/apps/web/app/[shortUrlId]/page.tsx
+++ b/apps/web/app/[shortUrlId]/page.tsx
@@ -2,6 +2,7 @@ import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { getShortUrl } from "@formbricks/lib/shortUrl/service";
+import { logger } from "@formbricks/logger";
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
export const generateMetadata = async (props): Promise => {
@@ -44,7 +45,7 @@ const Page = async (props) => {
try {
shortUrl = await getShortUrl(params.shortUrlId);
} catch (error) {
- console.error(error);
+ logger.error(error, "Could not fetch short url");
notFound();
}
diff --git a/apps/web/app/api/(internal)/csv-conversion/route.ts b/apps/web/app/api/(internal)/csv-conversion/route.ts
deleted file mode 100755
index 5e668022c9..0000000000
--- a/apps/web/app/api/(internal)/csv-conversion/route.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { responses } from "@/app/lib/api/response";
-import { authOptions } from "@/modules/auth/lib/authOptions";
-import { AsyncParser } from "@json2csv/node";
-import { getServerSession } from "next-auth";
-import { NextRequest } from "next/server";
-
-export const POST = async (request: NextRequest) => {
- const session = await getServerSession(authOptions);
-
- if (!session) {
- return responses.unauthorizedResponse();
- }
-
- const data = await request.json();
- let csv: string = "";
-
- const { json, fields, fileName } = data;
-
- const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
- const encodedFileName = encodeURIComponent(fileName)
- .replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
- .replace(/\*/g, "%2A");
-
- const parser = new AsyncParser({
- fields,
- });
-
- try {
- csv = await parser.parse(json).promise();
- } catch (err) {
- console.error(err);
- throw new Error("Failed to convert to CSV");
- }
-
- const headers = new Headers();
- headers.set("Content-Type", "text/csv;charset=utf-8;");
- headers.set(
- "Content-Disposition",
- `attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
- );
-
- return Response.json(
- {
- fileResponse: csv,
- },
- {
- headers,
- }
- );
-};
diff --git a/apps/web/app/api/(internal)/excel-conversion/route.ts b/apps/web/app/api/(internal)/excel-conversion/route.ts
deleted file mode 100755
index 76c092303a..0000000000
--- a/apps/web/app/api/(internal)/excel-conversion/route.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { responses } from "@/app/lib/api/response";
-import { authOptions } from "@/modules/auth/lib/authOptions";
-import { getServerSession } from "next-auth";
-import { NextRequest } from "next/server";
-import * as xlsx from "xlsx";
-
-export const POST = async (request: NextRequest) => {
- const session = await getServerSession(authOptions);
-
- if (!session) {
- return responses.unauthorizedResponse();
- }
-
- const data = await request.json();
-
- const { json, fields, fileName } = data;
-
- const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
- const encodedFileName = encodeURIComponent(fileName)
- .replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
- .replace(/\*/g, "%2A");
-
- const wb = xlsx.utils.book_new();
- const ws = xlsx.utils.json_to_sheet(json, { header: fields });
- xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
-
- const buffer = xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
- const base64String = buffer.toString("base64");
-
- const headers = new Headers();
-
- headers.set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
- headers.set(
- "Content-Disposition",
- `attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
- );
-
- return Response.json(
- {
- fileResponse: base64String,
- },
- {
- headers,
- }
- );
-};
diff --git a/apps/web/app/api/(internal)/insights/lib/utils.test.ts b/apps/web/app/api/(internal)/insights/lib/utils.test.ts
new file mode 100644
index 0000000000..f772f17a32
--- /dev/null
+++ b/apps/web/app/api/(internal)/insights/lib/utils.test.ts
@@ -0,0 +1,390 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
+import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
+import { mockSurveyOutput } from "@formbricks/lib/survey/tests/__mock__/survey.mock";
+import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils";
+import { ResourceNotFoundError } from "@formbricks/types/errors";
+import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
+import {
+ doesResponseHasAnyOpenTextAnswer,
+ generateInsightsEnabledForSurveyQuestions,
+ generateInsightsForSurvey,
+} from "./utils";
+
+// Mock all dependencies
+vi.mock("@formbricks/lib/constants", () => ({
+ CRON_SECRET: vi.fn(() => "mocked-cron-secret"),
+ WEBAPP_URL: "https://mocked-webapp-url.com",
+}));
+
+vi.mock("@formbricks/lib/survey/cache", () => ({
+ surveyCache: {
+ revalidate: vi.fn(),
+ },
+}));
+
+vi.mock("@formbricks/lib/survey/service", () => ({
+ getSurvey: vi.fn(),
+ updateSurvey: vi.fn(),
+}));
+
+vi.mock("@formbricks/lib/survey/utils", () => ({
+ doesSurveyHasOpenTextQuestion: vi.fn(),
+}));
+
+vi.mock("@formbricks/lib/utils/validate", () => ({
+ validateInputs: vi.fn(),
+}));
+
+// Mock global fetch
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+describe("Insights Utils", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("generateInsightsForSurvey", () => {
+ test("should call fetch with correct parameters", () => {
+ const surveyId = "survey-123";
+ mockFetch.mockResolvedValueOnce({ ok: true });
+
+ generateInsightsForSurvey(surveyId);
+
+ expect(mockFetch).toHaveBeenCalledWith(`${WEBAPP_URL}/api/insights`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": CRON_SECRET,
+ },
+ body: JSON.stringify({
+ surveyId,
+ }),
+ });
+ });
+
+ test("should handle errors and return error object", () => {
+ const surveyId = "survey-123";
+ mockFetch.mockImplementationOnce(() => {
+ throw new Error("Network error");
+ });
+
+ const result = generateInsightsForSurvey(surveyId);
+
+ expect(result).toEqual({
+ ok: false,
+ error: new Error("Error while generating insights for survey: Network error"),
+ });
+ });
+
+ test("should throw error if CRON_SECRET is not set", async () => {
+ // Reset modules to ensure clean state
+ vi.resetModules();
+
+ // Mock CRON_SECRET as undefined
+ vi.doMock("@formbricks/lib/constants", () => ({
+ CRON_SECRET: undefined,
+ WEBAPP_URL: "https://mocked-webapp-url.com",
+ }));
+
+ // Re-import the utils module to get the mocked CRON_SECRET
+ const { generateInsightsForSurvey } = await import("./utils");
+
+ expect(() => generateInsightsForSurvey("survey-123")).toThrow("CRON_SECRET is not set");
+
+ // Reset modules after test
+ vi.resetModules();
+ });
+ });
+
+ describe("generateInsightsEnabledForSurveyQuestions", () => {
+ test("should return success=false when survey has no open text questions", async () => {
+ // Mock data
+ const surveyId = "survey-123";
+ const mockSurvey: TSurvey = {
+ ...mockSurveyOutput,
+ type: "link",
+ segment: null,
+ displayPercentage: null,
+ questions: [
+ {
+ id: "cm8cjnse3000009jxf20v91ic",
+ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
+ headline: { default: "Question 1" },
+ required: true,
+ choices: [
+ {
+ id: "cm8cjnse3000009jxf20v91ic",
+ label: { default: "Choice 1" },
+ },
+ ],
+ },
+ {
+ id: "cm8cjo19c000109jx6znygc0u",
+ type: TSurveyQuestionTypeEnum.Rating,
+ headline: { default: "Question 2" },
+ required: true,
+ scale: "number",
+ range: 5,
+ isColorCodingEnabled: false,
+ },
+ ],
+ };
+
+ // Setup mocks
+ vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
+ vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(false);
+
+ // Execute function
+ const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
+
+ // Verify results
+ expect(result).toEqual({ success: false });
+ expect(updateSurvey).not.toHaveBeenCalled();
+ });
+
+ test("should return success=true when survey is updated with insights enabled", async () => {
+ vi.clearAllMocks();
+ // Mock data
+ const surveyId = "cm8ckvchx000008lb710n0gdn";
+
+ // Mock survey with open text questions that have no insightsEnabled property
+ const mockSurveyWithOpenTextQuestions: TSurvey = {
+ ...mockSurveyOutput,
+ id: surveyId,
+ type: "link",
+ segment: null,
+ displayPercentage: null,
+ questions: [
+ {
+ id: "cm8cjnse3000009jxf20v91ic",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question 1" },
+ required: true,
+ inputType: "text",
+ charLimit: {},
+ },
+ {
+ id: "cm8cjo19c000109jx6znygc0u",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question 2" },
+ required: true,
+ inputType: "text",
+ charLimit: {},
+ },
+ ],
+ };
+
+ // Define the updated survey that should be returned after updateSurvey
+ const mockUpdatedSurveyWithOpenTextQuestions: TSurvey = {
+ ...mockSurveyWithOpenTextQuestions,
+ questions: mockSurveyWithOpenTextQuestions.questions.map((q) => ({
+ ...q,
+ insightsEnabled: true, // Updated property
+ })),
+ };
+
+ // Setup mocks
+ vi.mocked(getSurvey).mockResolvedValueOnce(mockSurveyWithOpenTextQuestions);
+ vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
+ vi.mocked(updateSurvey).mockResolvedValueOnce(mockUpdatedSurveyWithOpenTextQuestions);
+
+ // Execute function
+ const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
+
+ expect(result).toEqual({
+ success: true,
+ survey: mockUpdatedSurveyWithOpenTextQuestions,
+ });
+ });
+
+ test("should return success=false when all open text questions already have insightsEnabled defined", async () => {
+ // Mock data
+ const surveyId = "survey-123";
+ const mockSurvey: TSurvey = {
+ ...mockSurveyOutput,
+ type: "link",
+ segment: null,
+ displayPercentage: null,
+ questions: [
+ {
+ id: "cm8cjnse3000009jxf20v91ic",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question 1" },
+ required: true,
+ inputType: "text",
+ charLimit: {},
+ insightsEnabled: true,
+ },
+ {
+ id: "cm8cjo19c000109jx6znygc0u",
+ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
+ headline: { default: "Question 2" },
+ required: true,
+ choices: [
+ {
+ id: "cm8cjnse3000009jxf20v91ic",
+ label: { default: "Choice 1" },
+ },
+ ],
+ },
+ ],
+ };
+
+ // Setup mocks
+ vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
+ vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
+
+ // Execute function
+ const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
+
+ // Verify results
+ expect(result).toEqual({ success: false });
+ expect(updateSurvey).not.toHaveBeenCalled();
+ });
+
+ test("should throw ResourceNotFoundError if survey is not found", async () => {
+ // Setup mocks
+ vi.mocked(getSurvey).mockResolvedValueOnce(null);
+
+ // Execute and verify function
+ await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(
+ new ResourceNotFoundError("Survey", "survey-123")
+ );
+ });
+
+ test("should throw ResourceNotFoundError if updateSurvey returns null", async () => {
+ // Mock data
+ const surveyId = "survey-123";
+ const mockSurvey: TSurvey = {
+ ...mockSurveyOutput,
+ type: "link",
+ segment: null,
+ displayPercentage: null,
+ questions: [
+ {
+ id: "cm8cjnse3000009jxf20v91ic",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question 1" },
+ required: true,
+ inputType: "text",
+ charLimit: {},
+ },
+ ],
+ };
+
+ // Setup mocks
+ vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
+ vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
+ // Type assertion to handle the null case
+ vi.mocked(updateSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
+
+ // Execute and verify function
+ await expect(generateInsightsEnabledForSurveyQuestions(surveyId)).rejects.toThrow(
+ new ResourceNotFoundError("Survey", surveyId)
+ );
+ });
+
+ test("should return success=false when no questions have insights enabled after update", async () => {
+ // Mock data
+ const surveyId = "survey-123";
+ const mockSurvey: TSurvey = {
+ ...mockSurveyOutput,
+ type: "link",
+ segment: null,
+ displayPercentage: null,
+ questions: [
+ {
+ id: "cm8cjnse3000009jxf20v91ic",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question 1" },
+ required: true,
+ inputType: "text",
+ charLimit: {},
+ insightsEnabled: false,
+ },
+ ],
+ };
+
+ // Setup mocks
+ vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
+ vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
+ vi.mocked(updateSurvey).mockResolvedValueOnce(mockSurvey);
+
+ // Execute function
+ const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
+
+ // Verify results
+ expect(result).toEqual({ success: false });
+ });
+
+ test("should propagate any errors that occur", async () => {
+ // Setup mocks
+ const testError = new Error("Test error");
+ vi.mocked(getSurvey).mockRejectedValueOnce(testError);
+
+ // Execute and verify function
+ await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(testError);
+ });
+ });
+
+ describe("doesResponseHasAnyOpenTextAnswer", () => {
+ test("should return true when at least one open text question has an answer", () => {
+ const openTextQuestionIds = ["q1", "q2", "q3"];
+ const response = {
+ q1: "",
+ q2: "This is an answer",
+ q3: "",
+ q4: "This is not an open text answer",
+ };
+
+ const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
+
+ expect(result).toBe(true);
+ });
+
+ test("should return false when no open text questions have answers", () => {
+ const openTextQuestionIds = ["q1", "q2", "q3"];
+ const response = {
+ q1: "",
+ q2: "",
+ q3: "",
+ q4: "This is not an open text answer",
+ };
+
+ const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
+
+ expect(result).toBe(false);
+ });
+
+ test("should return false when response does not contain any open text question IDs", () => {
+ const openTextQuestionIds = ["q1", "q2", "q3"];
+ const response = {
+ q4: "This is not an open text answer",
+ q5: "Another answer",
+ };
+
+ const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
+
+ expect(result).toBe(false);
+ });
+
+ test("should return false for non-string answers", () => {
+ const openTextQuestionIds = ["q1", "q2", "q3"];
+ const response = {
+ q1: "",
+ q2: 123,
+ q3: true,
+ } as any; // Use type assertion to handle mixed types in the test
+
+ const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/apps/web/app/api/(internal)/insights/lib/utils.ts b/apps/web/app/api/(internal)/insights/lib/utils.ts
index a4438acc40..c8feaf1ab2 100644
--- a/apps/web/app/api/(internal)/insights/lib/utils.ts
+++ b/apps/web/app/api/(internal)/insights/lib/utils.ts
@@ -4,12 +4,17 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
+import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
export const generateInsightsForSurvey = (surveyId: string) => {
+ if (!CRON_SECRET) {
+ throw new Error("CRON_SECRET is not set");
+ }
+
try {
return fetch(`${WEBAPP_URL}/api/insights`, {
method: "POST",
@@ -80,7 +85,7 @@ export const generateInsightsEnabledForSurveyQuestions = async (
return { success: false };
} catch (error) {
- console.error("Error generating insights for surveys:", error);
+ logger.error(error, "Error generating insights for surveys");
throw error;
}
};
diff --git a/apps/web/app/api/(internal)/insights/route.ts b/apps/web/app/api/(internal)/insights/route.ts
index 0d7037a618..c4a2c8f47d 100644
--- a/apps/web/app/api/(internal)/insights/route.ts
+++ b/apps/web/app/api/(internal)/insights/route.ts
@@ -5,6 +5,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { headers } from "next/headers";
import { z } from "zod";
import { CRON_SECRET } from "@formbricks/lib/constants";
+import { logger } from "@formbricks/logger";
import { generateInsightsEnabledForSurveyQuestions } from "./lib/utils";
export const maxDuration = 300; // This function can run for a maximum of 300 seconds
@@ -25,7 +26,7 @@ export const POST = async (request: Request) => {
const inputValidation = ZGenerateInsightsInput.safeParse(jsonInput);
if (!inputValidation.success) {
- console.error(inputValidation.error);
+ logger.error({ error: inputValidation.error, url: request.url }, "Error in POST /api/insights");
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
diff --git a/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts
index aa0cacb4a8..5eea313aaa 100644
--- a/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts
+++ b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts
@@ -9,6 +9,7 @@ import { writeDataToSlack } from "@formbricks/lib/slack/service";
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { truncateText } from "@formbricks/lib/utils/strings";
+import { logger } from "@formbricks/logger";
import { Result } from "@formbricks/types/error-handlers";
import { TIntegration, TIntegrationType } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -83,13 +84,13 @@ export const handleIntegrations = async (
survey
);
if (!googleResult.ok) {
- console.error("Error in google sheets integration: ", googleResult.error);
+ logger.error(googleResult.error, "Error in google sheets integration");
}
break;
case "slack":
const slackResult = await handleSlackIntegration(integration as TIntegrationSlack, data, survey);
if (!slackResult.ok) {
- console.error("Error in slack integration: ", slackResult.error);
+ logger.error(slackResult.error, "Error in slack integration");
}
break;
case "airtable":
@@ -99,13 +100,13 @@ export const handleIntegrations = async (
survey
);
if (!airtableResult.ok) {
- console.error("Error in airtable integration: ", airtableResult.error);
+ logger.error(airtableResult.error, "Error in airtable integration");
}
break;
case "notion":
const notionResult = await handleNotionIntegration(integration as TIntegrationNotion, data, survey);
if (!notionResult.ok) {
- console.error("Error in notion integration: ", notionResult.error);
+ logger.error(notionResult.error, "Error in notion integration");
}
break;
}
@@ -418,7 +419,7 @@ const getValue = (colType: string, value: string | string[] | Date | number | Re
return typeof value === "string" ? value : (value as string[]).join(", ");
}
} catch (error) {
- console.error(error);
+ logger.error(error, "Payload build failed!");
throw new Error("Payload build failed!");
}
};
diff --git a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts b/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts
index 240b0d09ff..e2d1115116 100644
--- a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts
+++ b/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts
@@ -1,6 +1,7 @@
import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
+import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -89,6 +90,6 @@ export const sendSurveyFollowUps = async (
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
- console.error("Follow-up processing errors:", errors);
+ logger.error(errors, "Follow-up processing errors");
}
};
diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts
index c2739e4877..e98ac5208a 100644
--- a/apps/web/app/api/(internal)/pipeline/route.ts
+++ b/apps/web/app/api/(internal)/pipeline/route.ts
@@ -19,6 +19,7 @@ import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
import { getPromptText } from "@formbricks/lib/utils/ai";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
+import { logger } from "@formbricks/logger";
import { handleIntegrations } from "./lib/handleIntegrations";
export const POST = async (request: Request) => {
@@ -34,7 +35,10 @@ export const POST = async (request: Request) => {
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
if (!inputValidation.success) {
- console.error(inputValidation.error);
+ logger.error(
+ { error: inputValidation.error, url: request.url },
+ "Error in POST /api/(internal)/pipeline"
+ );
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
@@ -87,7 +91,7 @@ export const POST = async (request: Request) => {
data: response,
}),
}).catch((error) => {
- console.error(`Webhook call to ${webhook.url} failed:`, error);
+ logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
})
);
@@ -100,7 +104,7 @@ export const POST = async (request: Request) => {
]);
if (!survey) {
- console.error(`Survey with id ${surveyId} not found`);
+ logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return new Response("Survey not found", { status: 404 });
}
@@ -172,7 +176,10 @@ export const POST = async (request: Request) => {
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
- console.error(`Failed to send email to ${user.email}:`, error);
+ logger.error(
+ { error, url: request.url, userEmail: user.email },
+ `Failed to send email to ${user.email}`
+ );
})
);
@@ -188,7 +195,7 @@ export const POST = async (request: Request) => {
const results = await Promise.allSettled([...webhookPromises, ...emailPromises]);
results.forEach((result) => {
if (result.status === "rejected") {
- console.error("Promise rejected:", result.reason);
+ logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
@@ -228,7 +235,7 @@ export const POST = async (request: Request) => {
text,
});
} catch (e) {
- console.error(e);
+ logger.error({ error: e, url: request.url }, "Error creating document and assigning insight");
}
}
}
@@ -240,7 +247,7 @@ export const POST = async (request: Request) => {
const results = await Promise.allSettled(webhookPromises);
results.forEach((result) => {
if (result.status === "rejected") {
- console.error("Promise rejected:", result.reason);
+ logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
}
diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
index a0f90078ea..306a488ae5 100644
--- a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
@@ -21,6 +21,7 @@ import {
} from "@formbricks/lib/posthogServer";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
+import { logger } from "@formbricks/logger";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -103,7 +104,7 @@ export const GET = async (
},
});
} catch (error) {
- console.error(`Error sending plan limits reached event to Posthog: ${error}`);
+ logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`);
}
}
}
@@ -187,7 +188,10 @@ export const GET = async (
return responses.successResponse({ ...state }, true);
} catch (error) {
- console.error(error);
+ logger.error(
+ { error, url: request.url },
+ "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"
+ );
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
};
diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts
index fe269b75a8..949c0d6ea1 100644
--- a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts
@@ -14,6 +14,7 @@ import { getSurveys } from "@formbricks/lib/survey/service";
import { anySurveyHasFilters } from "@formbricks/lib/survey/utils";
import { diffInDays } from "@formbricks/lib/utils/datetime";
import { validateInputs } from "@formbricks/lib/utils/validate";
+import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -150,7 +151,7 @@ export const getSyncSurveys = reactCache(
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
- console.error(error);
+ logger.error(error);
throw new DatabaseError(error.message);
}
diff --git a/apps/web/app/api/v1/client/[environmentId]/contacts/[userId]/attributes/route.ts b/apps/web/app/api/v1/client/[environmentId]/contacts/[userId]/attributes/route.ts
index f2943a511c..0d30268d09 100644
--- a/apps/web/app/api/v1/client/[environmentId]/contacts/[userId]/attributes/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/contacts/[userId]/attributes/route.ts
@@ -1,6 +1,6 @@
import {
OPTIONS,
PUT,
-} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
+} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };
diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/route.ts b/apps/web/app/api/v1/client/[environmentId]/displays/route.ts
index 12e526fb68..478ea47041 100644
--- a/apps/web/app/api/v1/client/[environmentId]/displays/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/displays/route.ts
@@ -2,6 +2,7 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
+import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError } from "@formbricks/types/errors";
import { createDisplay } from "./lib/display";
@@ -48,7 +49,7 @@ export const POST = async (request: Request, context: Context): Promise transformPrismaSurvey(survey));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
- console.error(error);
+ logger.error(error, "Error getting surveys for environment state");
throw new DatabaseError(error.message);
}
throw error;
diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts
index a57d36109e..0f99348595 100644
--- a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts
@@ -3,6 +3,7 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest } from "next/server";
import { environmentCache } from "@formbricks/lib/environment/cache";
+import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZJsSyncInput } from "@formbricks/types/js";
@@ -11,7 +12,7 @@ export const OPTIONS = async (): Promise => {
};
export const GET = async (
- _: NextRequest,
+ request: NextRequest,
props: {
params: Promise<{
environmentId: string;
@@ -58,11 +59,14 @@ export const GET = async (
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
- console.error(err);
+ logger.error(
+ { error: err, url: request.url },
+ "Error in GET /api/v1/client/[environmentId]/environment"
+ );
return responses.internalServerErrorResponse(err.message, true);
}
} catch (error) {
- console.error(error);
+ logger.error({ error, url: request.url }, "Error in GET /api/v1/client/[environmentId]/environment");
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
};
diff --git a/apps/web/app/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts
index b81a65e3b3..811f041294 100644
--- a/apps/web/app/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts
@@ -1,6 +1,6 @@
import {
GET,
OPTIONS,
-} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
+} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };
diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts
index 94ee53a57c..bc54dcb4d7 100644
--- a/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts
@@ -3,6 +3,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
+import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -45,7 +46,10 @@ export const PUT = async (
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
- console.error(error);
+ logger.error(
+ { error, url: request.url },
+ "Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
+ );
return responses.internalServerErrorResponse(error.message);
}
}
@@ -59,7 +63,10 @@ export const PUT = async (
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
- console.error(error);
+ logger.error(
+ { error, url: request.url },
+ "Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
+ );
return responses.internalServerErrorResponse(error.message);
}
}
diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts
index 56ffb86327..d961371381 100644
--- a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts
@@ -12,6 +12,7 @@ import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate";
+import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -178,7 +179,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise => {
};
export const POST = async (req: NextRequest, context: Context): Promise => {
+ if (!ENCRYPTION_KEY) {
+ return responses.internalServerErrorResponse("Encryption key is not set");
+ }
const params = await context.params;
const environmentId = params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
- const formData = await req.json();
- const fileType = formData.fileType as string;
- const encodedFileName = formData.fileName as string;
- const surveyId = formData.surveyId as string;
- const signedSignature = formData.signature as string;
- const signedUuid = formData.uuid as string;
- const signedTimestamp = formData.timestamp as string;
+ const jsonInput = await req.json();
+ const fileType = jsonInput.fileType as string;
+ const encodedFileName = jsonInput.fileName as string;
+ const surveyId = jsonInput.surveyId as string;
+ const signedSignature = jsonInput.signature as string;
+ const signedUuid = jsonInput.uuid as string;
+ const signedTimestamp = jsonInput.timestamp as string;
if (!fileType) {
return responses.badRequestResponse("contentType is required");
@@ -98,7 +102,7 @@ export const POST = async (req: NextRequest, context: Context): Promise {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -77,7 +78,7 @@ export const GET = async (req: NextRequest) => {
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
} catch (error) {
- console.error(error);
+ logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
responses.internalServerErrorResponse(error);
}
responses.badRequestResponse("unknown error occurred");
diff --git a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts
index c9ab0f9ba8..6811814800 100644
--- a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts
+++ b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts
@@ -2,6 +2,7 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
+import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -54,7 +55,7 @@ export const PUT = async (
try {
actionClassUpdate = await request.json();
} catch (error) {
- console.error(`Error parsing JSON: ${error}`);
+ logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
diff --git a/apps/web/app/api/v1/management/action-classes/route.ts b/apps/web/app/api/v1/management/action-classes/route.ts
index 6a032ee6e3..4bfc2922f8 100644
--- a/apps/web/app/api/v1/management/action-classes/route.ts
+++ b/apps/web/app/api/v1/management/action-classes/route.ts
@@ -2,6 +2,7 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
+import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
@@ -28,7 +29,7 @@ export const POST = async (request: Request): Promise => {
try {
actionClassInput = await request.json();
} catch (error) {
- console.error(`Error parsing JSON input: ${error}`);
+ logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
diff --git a/apps/web/app/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/app/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts
index 7a0a88f40d..646b58f786 100644
--- a/apps/web/app/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts
+++ b/apps/web/app/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts
@@ -2,6 +2,6 @@ import {
DELETE,
GET,
PUT,
-} from "@/modules/ee/contacts/api/management/contact-attribute-keys/[contactAttributeKeyId]/route";
+} from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route";
export { DELETE, GET, PUT };
diff --git a/apps/web/app/api/v1/management/contact-attribute-keys/route.ts b/apps/web/app/api/v1/management/contact-attribute-keys/route.ts
index a9eff8127f..e40b29f2b7 100644
--- a/apps/web/app/api/v1/management/contact-attribute-keys/route.ts
+++ b/apps/web/app/api/v1/management/contact-attribute-keys/route.ts
@@ -1,3 +1,3 @@
-import { GET, POST } from "@/modules/ee/contacts/api/management/contact-attribute-keys/route";
+import { GET, POST } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/route";
export { GET, POST };
diff --git a/apps/web/app/api/v1/management/contact-attributes/route.ts b/apps/web/app/api/v1/management/contact-attributes/route.ts
index 4e26dd5645..72199b2630 100644
--- a/apps/web/app/api/v1/management/contact-attributes/route.ts
+++ b/apps/web/app/api/v1/management/contact-attributes/route.ts
@@ -1,3 +1,3 @@
-import { GET } from "@/modules/ee/contacts/api/management/contact-attributes/route";
+import { GET } from "@/modules/ee/contacts/api/v1/management/contact-attributes/route";
export { GET };
diff --git a/apps/web/app/api/v1/management/contacts/[contactId]/route.ts b/apps/web/app/api/v1/management/contacts/[contactId]/route.ts
index a9598fd22c..f4acf186b4 100644
--- a/apps/web/app/api/v1/management/contacts/[contactId]/route.ts
+++ b/apps/web/app/api/v1/management/contacts/[contactId]/route.ts
@@ -1,3 +1,3 @@
-import { DELETE, GET } from "@/modules/ee/contacts/api/management/contacts/[contactId]/route";
+import { DELETE, GET } from "@/modules/ee/contacts/api/v1/management/contacts/[contactId]/route";
export { DELETE, GET };
diff --git a/apps/web/app/api/v1/management/contacts/route.ts b/apps/web/app/api/v1/management/contacts/route.ts
index 05c986e528..7e826822e3 100644
--- a/apps/web/app/api/v1/management/contacts/route.ts
+++ b/apps/web/app/api/v1/management/contacts/route.ts
@@ -1,4 +1,4 @@
-import { GET } from "@/modules/ee/contacts/api/management/contacts/route";
+import { GET } from "@/modules/ee/contacts/api/v1/management/contacts/route";
export { GET };
diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts
index 2eeefb829b..28f7c7d304 100644
--- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts
+++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts
@@ -4,6 +4,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
+import { logger } from "@formbricks/logger";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses";
const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise => {
@@ -77,7 +78,7 @@ export const PUT = async (
try {
responseUpdate = await request.json();
} catch (error) {
- console.error(`Error parsing JSON: ${error}`);
+ logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
diff --git a/apps/web/app/api/v1/management/responses/lib/response.ts b/apps/web/app/api/v1/management/responses/lib/response.ts
index 56ffb86327..d961371381 100644
--- a/apps/web/app/api/v1/management/responses/lib/response.ts
+++ b/apps/web/app/api/v1/management/responses/lib/response.ts
@@ -12,6 +12,7 @@ import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate";
+import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -178,7 +179,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise => {
try {
jsonInput = await request.json();
} catch (err) {
- console.error(`Error parsing JSON input: ${err}`);
+ logger.error({ error: err, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
@@ -92,7 +93,7 @@ export const POST = async (request: Request): Promise => {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
- console.error(error);
+ logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
}
}
diff --git a/apps/web/app/api/v1/management/storage/local/route.ts b/apps/web/app/api/v1/management/storage/local/route.ts
index f4e8c8f00d..4c1398903e 100644
--- a/apps/web/app/api/v1/management/storage/local/route.ts
+++ b/apps/web/app/api/v1/management/storage/local/route.ts
@@ -12,6 +12,10 @@ import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { putFileToLocalStorage } from "@formbricks/lib/storage/service";
export const POST = async (req: NextRequest): Promise => {
+ if (!ENCRYPTION_KEY) {
+ return responses.internalServerErrorResponse("Encryption key is not set");
+ }
+
const accessType = "public"; // public files are accessible by anyone
const headersList = await headers();
diff --git a/apps/web/app/api/v1/management/storage/route.ts b/apps/web/app/api/v1/management/storage/route.ts
index 1f0ecdc86b..9a5060b2be 100644
--- a/apps/web/app/api/v1/management/storage/route.ts
+++ b/apps/web/app/api/v1/management/storage/route.ts
@@ -3,6 +3,7 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
+import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
// api endpoint for uploading public files
@@ -17,7 +18,7 @@ export const POST = async (req: NextRequest): Promise => {
try {
storageInput = await req.json();
} catch (error) {
- console.error(`Error parsing JSON input: ${error}`);
+ logger.error({ error, url: req.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts
index bcf696a893..c70179f17b 100644
--- a/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts
+++ b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts
@@ -5,6 +5,7 @@ import { segmentCache } from "@formbricks/lib/cache/segment";
import { responseCache } from "@formbricks/lib/response/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
+import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
export const deleteSurvey = async (surveyId: string) => {
@@ -67,7 +68,7 @@ export const deleteSurvey = async (surveyId: string) => {
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
- console.error(error);
+ logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts
index 4c44d923ad..34749304bc 100644
--- a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts
+++ b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts
@@ -6,6 +6,7 @@ import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
+import { logger } from "@formbricks/logger";
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise => {
@@ -79,7 +80,7 @@ export const PUT = async (
try {
surveyUpdate = await request.json();
} catch (error) {
- console.error(`Error parsing JSON input: ${error}`);
+ logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts
index b29ead100e..7b883a3720 100644
--- a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts
+++ b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts
@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { NextRequest } from "next/server";
+import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getSurvey } from "@formbricks/lib/survey/service";
import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys";
@@ -36,9 +37,10 @@ export const GET = async (
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
+ const surveyDomain = getSurveyDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
- (singleUseId) => `${process.env.WEBAPP_URL}/s/${survey.id}?suId=${singleUseId}`
+ (singleUseId) => `${surveyDomain}/s/${survey.id}?suId=${singleUseId}`
);
return responses.successResponse(surveyLinks);
diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts
index 029c5c29fc..06ee2cee4d 100644
--- a/apps/web/app/api/v1/management/surveys/route.ts
+++ b/apps/web/app/api/v1/management/surveys/route.ts
@@ -5,6 +5,7 @@ import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
+import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
@@ -41,7 +42,7 @@ export const POST = async (request: Request): Promise => {
try {
surveyInput = await request.json();
} catch (error) {
- console.error(`Error parsing JSON: ${error}`);
+ logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
diff --git a/apps/web/app/api/v1/og/route.tsx b/apps/web/app/api/v1/og/route.tsx
index 4b79654d98..bc8b17d5b7 100644
--- a/apps/web/app/api/v1/og/route.tsx
+++ b/apps/web/app/api/v1/og/route.tsx
@@ -29,7 +29,6 @@ export const GET = async (req: NextRequest) => {
{name}
- Complete in ~ 4 minutes
diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts
index 9f299ee840..4e7ffb9a47 100644
--- a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts
+++ b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts
@@ -1,6 +1,7 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
+import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
@@ -25,7 +26,10 @@ export const deleteWebhook = async (id: string): Promise
=> {
return deletedWebhook;
} catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === PrismaErrorType.RelatedRecordDoesNotExist
+ ) {
throw new ResourceNotFoundError("Webhook", id);
}
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts
index a5a9ed9f43..9dd12a4c47 100644
--- a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts
+++ b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts
@@ -2,6 +2,7 @@ import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { headers } from "next/headers";
+import { logger } from "@formbricks/logger";
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
@@ -26,7 +27,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
return responses.successResponse(webhook);
};
-export const DELETE = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
+export const DELETE = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
@@ -52,7 +53,7 @@ export const DELETE = async (_: Request, props: { params: Promise<{ webhookId: s
const webhook = await deleteWebhook(params.webhookId);
return responses.successResponse(webhook);
} catch (e) {
- console.error(e.message);
+ logger.error({ error: e, url: request.url }, "Error deleting webhook");
return responses.notFoundResponse("Webhook", params.webhookId);
}
};
diff --git a/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts b/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts
index f2943a511c..0d30268d09 100644
--- a/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts
+++ b/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts
@@ -1,6 +1,6 @@
import {
OPTIONS,
PUT,
-} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
+} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };
diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/route.ts b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts
index bddc7cb7de..f91d3f1347 100644
--- a/apps/web/app/api/v2/client/[environmentId]/displays/route.ts
+++ b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts
@@ -3,6 +3,7 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
+import { logger } from "@formbricks/logger";
import { InvalidInputError } from "@formbricks/types/errors";
import { createDisplay } from "./lib/display";
@@ -48,7 +49,7 @@ export const POST = async (request: Request, context: Context): Promise ({
default: vi.fn(),
}));
diff --git a/apps/web/app/layout.test.tsx b/apps/web/app/layout.test.tsx
new file mode 100644
index 0000000000..f7e9ebc18d
--- /dev/null
+++ b/apps/web/app/layout.test.tsx
@@ -0,0 +1,112 @@
+import { getLocale } from "@/tolgee/language";
+import { getTolgee } from "@/tolgee/server";
+import { cleanup, render, screen } from "@testing-library/react";
+import { TolgeeInstance } from "@tolgee/react";
+import React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import RootLayout from "./layout";
+
+// Mock dependencies for the layout
+
+vi.mock("@formbricks/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+ POSTHOG_API_KEY: "mock-posthog-api-key",
+ POSTHOG_HOST: "mock-posthog-host",
+ IS_POSTHOG_CONFIGURED: true,
+ ENCRYPTION_KEY: "mock-encryption-key",
+ ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
+ GITHUB_ID: "mock-github-id",
+ GITHUB_SECRET: "test-githubID",
+ GOOGLE_CLIENT_ID: "test-google-client-id",
+ GOOGLE_CLIENT_SECRET: "test-google-client-secret",
+ AZUREAD_CLIENT_ID: "test-azuread-client-id",
+ AZUREAD_CLIENT_SECRET: "test-azure",
+ AZUREAD_TENANT_ID: "test-azuread-tenant-id",
+ OIDC_DISPLAY_NAME: "test-oidc-display-name",
+ OIDC_CLIENT_ID: "test-oidc-client-id",
+ OIDC_ISSUER: "test-oidc-issuer",
+ OIDC_CLIENT_SECRET: "test-oidc-client-secret",
+ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
+ WEBAPP_URL: "test-webapp-url",
+ IS_PRODUCTION: false,
+ SENTRY_DSN: "mock-sentry-dsn",
+}));
+
+vi.mock("@/tolgee/language", () => ({
+ getLocale: vi.fn(),
+}));
+
+vi.mock("@/tolgee/server", () => ({
+ getTolgee: vi.fn(),
+}));
+
+vi.mock("@vercel/speed-insights/next", () => ({
+ SpeedInsights: () => SpeedInsights
,
+}));
+
+vi.mock("@/modules/ui/components/post-hog-client", () => ({
+ PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
+
+ PHProvider: {posthogEnabled}
+ {children}
+
+ ),
+}));
+
+vi.mock("@/tolgee/client", () => ({
+ TolgeeNextProvider: ({
+ children,
+ language,
+ staticData,
+ }: {
+ children: React.ReactNode;
+ language: string;
+ staticData: any;
+ }) => (
+
+ TolgeeNextProvider: {language} {JSON.stringify(staticData)}
+ {children}
+
+ ),
+}));
+
+vi.mock("@/app/sentry/SentryProvider", () => ({
+ SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
+
+ SentryProvider: {sentryDsn}
+ {children}
+
+ ),
+}));
+
+describe("RootLayout", () => {
+ beforeEach(() => {
+ cleanup();
+ process.env.VERCEL = "1";
+ });
+
+ it("renders the layout with the correct structure and providers", async () => {
+ const fakeLocale = "en-US";
+ // Mock getLocale to resolve to a fake locale
+ vi.mocked(getLocale).mockResolvedValue(fakeLocale);
+
+ const fakeStaticData = { key: "value" };
+ const fakeTolgee = {
+ loadRequired: vi.fn().mockResolvedValue(fakeStaticData),
+ };
+ // Mock getTolgee to return our fake tolgee object
+ vi.mocked(getTolgee).mockResolvedValue(fakeTolgee as unknown as TolgeeInstance);
+
+ const children = Child Content
;
+ const element = await RootLayout({ children });
+ render(element);
+
+ // log env vercel
+ console.log("vercel", process.env.VERCEL);
+
+ expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
+ expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
+ expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
+ expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
+ });
+});
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 0bbf609d63..74545f4a81 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,10 +1,12 @@
-import { PHProvider } from "@/modules/ui/components/post-hog-client";
+import { SentryProvider } from "@/app/sentry/SentryProvider";
import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
import { TolgeeStaticData } from "@tolgee/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Metadata } from "next";
+import React from "react";
+import { SENTRY_DSN } from "@formbricks/lib/constants";
import "../modules/ui/globals.css";
export const metadata: Metadata = {
@@ -23,13 +25,13 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
- {process.env.VERCEL === "1" && }
-
+ {process.env.VERCEL === "1" && }
+
{children}
-
+
);
diff --git a/apps/web/app/lib/fetchFile.ts b/apps/web/app/lib/fetchFile.ts
deleted file mode 100755
index ddba2b4244..0000000000
--- a/apps/web/app/lib/fetchFile.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export const fetchFile = async (
- data: { json: any; fields?: string[]; fileName?: string },
- filetype: string
-) => {
- const endpoint = filetype === "csv" ? "csv-conversion" : "excel-conversion";
-
- const response = await fetch(`/api/${endpoint}`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(data),
- });
-
- if (!response.ok) throw new Error("Failed to convert to file");
-
- return response.json();
-};
diff --git a/apps/web/app/lib/pipelines.test.ts b/apps/web/app/lib/pipelines.test.ts
new file mode 100644
index 0000000000..306a4260d5
--- /dev/null
+++ b/apps/web/app/lib/pipelines.test.ts
@@ -0,0 +1,113 @@
+import { TPipelineInput } from "@/app/lib/types/pipelines";
+import { PipelineTriggers } from "@prisma/client";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { logger } from "@formbricks/logger";
+import { TResponse } from "@formbricks/types/responses";
+import { sendToPipeline } from "./pipelines";
+
+// Mock the constants module
+vi.mock("@formbricks/lib/constants", () => ({
+ CRON_SECRET: "mocked-cron-secret",
+ WEBAPP_URL: "https://test.formbricks.com",
+}));
+
+// Mock the logger
+vi.mock("@formbricks/logger", () => ({
+ logger: {
+ error: vi.fn(),
+ },
+}));
+
+// Mock global fetch
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+describe("pipelines", () => {
+ // Reset mocks before each test
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // Clean up after each test
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("sendToPipeline should call fetch with correct parameters", async () => {
+ // Mock the fetch implementation to return a successful response
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ // Create sample data for testing
+ const testData: TPipelineInput = {
+ event: PipelineTriggers.responseCreated,
+ surveyId: "cm8ckvchx000008lb710n0gdn",
+ environmentId: "cm8cmp9hp000008jf7l570ml2",
+ response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
+ };
+
+ // Call the function with test data
+ await sendToPipeline(testData);
+
+ // Check that fetch was called with the correct arguments
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(mockFetch).toHaveBeenCalledWith("https://test.formbricks.com/api/pipeline", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": "mocked-cron-secret",
+ },
+ body: JSON.stringify({
+ environmentId: testData.environmentId,
+ surveyId: testData.surveyId,
+ event: testData.event,
+ response: testData.response,
+ }),
+ });
+ });
+
+ test("sendToPipeline should handle fetch errors", async () => {
+ // Mock fetch to throw an error
+ const testError = new Error("Network error");
+ mockFetch.mockRejectedValueOnce(testError);
+
+ // Create sample data for testing
+ const testData: TPipelineInput = {
+ event: PipelineTriggers.responseCreated,
+ surveyId: "cm8ckvchx000008lb710n0gdn",
+ environmentId: "cm8cmp9hp000008jf7l570ml2",
+ response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
+ };
+
+ // Call the function
+ await sendToPipeline(testData);
+
+ // Check that the error was logged using logger
+ expect(logger.error).toHaveBeenCalledWith(testError, "Error sending event to pipeline");
+ });
+
+ test("sendToPipeline should throw error if CRON_SECRET is not set", async () => {
+ // For this test, we need to mock CRON_SECRET as undefined
+ // Let's use a more compatible approach to reset the mocks
+ const originalModule = await import("@formbricks/lib/constants");
+ const mockConstants = { ...originalModule, CRON_SECRET: undefined };
+
+ vi.doMock("@formbricks/lib/constants", () => mockConstants);
+
+ // Re-import the module to get the new mocked values
+ const { sendToPipeline: sendToPipelineNoSecret } = await import("./pipelines");
+
+ // Create sample data for testing
+ const testData: TPipelineInput = {
+ event: PipelineTriggers.responseCreated,
+ surveyId: "cm8ckvchx000008lb710n0gdn",
+ environmentId: "cm8cmp9hp000008jf7l570ml2",
+ response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
+ };
+
+ // Expect the function to throw an error
+ await expect(sendToPipelineNoSecret(testData)).rejects.toThrow("CRON_SECRET is not set");
+ });
+});
diff --git a/apps/web/app/lib/pipelines.ts b/apps/web/app/lib/pipelines.ts
index 47a1de595b..d1f040efa2 100644
--- a/apps/web/app/lib/pipelines.ts
+++ b/apps/web/app/lib/pipelines.ts
@@ -1,7 +1,12 @@
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
+import { logger } from "@formbricks/logger";
export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => {
+ if (!CRON_SECRET) {
+ throw new Error("CRON_SECRET is not set");
+ }
+
return fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
@@ -15,6 +20,6 @@ export const sendToPipeline = async ({ event, surveyId, environmentId, response
response,
}),
}).catch((error) => {
- console.error(`Error sending event to pipeline: ${error}`);
+ logger.error(error, "Error sending event to pipeline");
});
};
diff --git a/apps/web/app/lib/singleUseSurveys.test.ts b/apps/web/app/lib/singleUseSurveys.test.ts
new file mode 100644
index 0000000000..c941c135d4
--- /dev/null
+++ b/apps/web/app/lib/singleUseSurveys.test.ts
@@ -0,0 +1,120 @@
+import cuid2 from "@paralleldrive/cuid2";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import * as crypto from "@formbricks/lib/crypto";
+import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
+
+// Mock the crypto module
+vi.mock("@formbricks/lib/crypto", () => ({
+ symmetricEncrypt: vi.fn(),
+ symmetricDecrypt: vi.fn(),
+ decryptAES128: vi.fn(),
+}));
+
+// Mock constants
+vi.mock("@formbricks/lib/constants", () => ({
+ ENCRYPTION_KEY: "test-encryption-key",
+ FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
+}));
+
+// Mock cuid2
+vi.mock("@paralleldrive/cuid2", () => {
+ const createIdMock = vi.fn();
+ const isCuidMock = vi.fn();
+
+ return {
+ default: {
+ createId: createIdMock,
+ isCuid: isCuidMock,
+ },
+ createId: createIdMock,
+ isCuid: isCuidMock,
+ };
+});
+
+describe("generateSurveySingleUseId", () => {
+ const mockCuid = "test-cuid-123";
+ const mockEncryptedCuid = "encrypted-cuid-123";
+
+ beforeEach(() => {
+ // Setup mocks
+ vi.mocked(cuid2.createId).mockReturnValue(mockCuid);
+ vi.mocked(crypto.symmetricEncrypt).mockReturnValue(mockEncryptedCuid);
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("returns unencrypted cuid when isEncrypted is false", () => {
+ const result = generateSurveySingleUseId(false);
+
+ expect(result).toBe(mockCuid);
+ expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
+ });
+
+ it("returns encrypted cuid when isEncrypted is true", () => {
+ const result = generateSurveySingleUseId(true);
+
+ expect(result).toBe(mockEncryptedCuid);
+ expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key");
+ });
+
+ it("returns undefined when cuid is not valid", () => {
+ vi.mocked(cuid2.isCuid).mockReturnValue(false);
+
+ const result = validateSurveySingleUseId(mockEncryptedCuid);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("returns undefined when decryption fails", () => {
+ vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => {
+ throw new Error("Decryption failed");
+ });
+
+ const result = validateSurveySingleUseId(mockEncryptedCuid);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => {
+ // Temporarily mock ENCRYPTION_KEY as undefined
+ vi.doMock("@formbricks/lib/constants", () => ({
+ ENCRYPTION_KEY: undefined,
+ FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
+ }));
+
+ // Re-import to get the new mock values
+ const { generateSurveySingleUseId: generateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
+
+ expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set");
+ });
+
+ it("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => {
+ // Temporarily mock ENCRYPTION_KEY as undefined
+ vi.doMock("@formbricks/lib/constants", () => ({
+ ENCRYPTION_KEY: undefined,
+ FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
+ }));
+
+ // Re-import to get the new mock values
+ const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
+
+ expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set");
+ });
+
+ it("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => {
+ // Temporarily mock FORMBRICKS_ENCRYPTION_KEY as undefined
+ vi.doMock("@formbricks/lib/constants", () => ({
+ ENCRYPTION_KEY: "test-encryption-key",
+ FORMBRICKS_ENCRYPTION_KEY: undefined,
+ }));
+
+ // Re-import to get the new mock values
+ const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
+
+ expect(() =>
+ validateSurveySingleUseIdNoKey("M(.Bob=dS1!wUSH2lb,E7hxO=He1cnnitmXrG|Su/DKYZrPy~zgS)u?dgI53sfs/")
+ ).toThrow("FORMBRICKS_ENCRYPTION_KEY is not defined");
+ });
+});
diff --git a/apps/web/app/lib/singleUseSurveys.ts b/apps/web/app/lib/singleUseSurveys.ts
index 33318b6567..aaceacd6d9 100644
--- a/apps/web/app/lib/singleUseSurveys.ts
+++ b/apps/web/app/lib/singleUseSurveys.ts
@@ -9,31 +9,42 @@ export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
return cuid;
}
+ if (!ENCRYPTION_KEY) {
+ throw new Error("ENCRYPTION_KEY is not set");
+ }
+
const encryptedCuid = symmetricEncrypt(cuid, ENCRYPTION_KEY);
return encryptedCuid;
};
// validate the survey single use id
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
- try {
- let decryptedCuid: string | null = null;
+ let decryptedCuid: string | null = null;
- if (surveySingleUseId.length === 64) {
- if (!FORMBRICKS_ENCRYPTION_KEY) {
- throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
- }
-
- decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
- } else {
- decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY);
+ if (surveySingleUseId.length === 64) {
+ if (!FORMBRICKS_ENCRYPTION_KEY) {
+ throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
}
- if (cuid2.isCuid(decryptedCuid)) {
- return decryptedCuid;
- } else {
+ try {
+ decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY, surveySingleUseId);
+ } catch (error) {
return undefined;
}
- } catch (error) {
+ } else {
+ if (!ENCRYPTION_KEY) {
+ throw new Error("ENCRYPTION_KEY is not set");
+ }
+ try {
+ decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY);
+ } catch (error) {
+ return undefined;
+ }
+ }
+
+ if (cuid2.isCuid(decryptedCuid)) {
+ return decryptedCuid;
+ } else {
return undefined;
}
};
diff --git a/apps/web/app/middleware/rate-limit.ts b/apps/web/app/middleware/rate-limit.ts
index 4025ea63ff..4c9dc467a7 100644
--- a/apps/web/app/middleware/rate-limit.ts
+++ b/apps/web/app/middleware/rate-limit.ts
@@ -1,5 +1,6 @@
import { LRUCache } from "lru-cache";
import { ENTERPRISE_LICENSE_KEY, REDIS_HTTP_URL } from "@formbricks/lib/constants";
+import { logger } from "@formbricks/logger";
interface Options {
interval: number;
@@ -28,8 +29,7 @@ const redisRateLimiter = (options: Options) => async (token: string) => {
}
const tokenCountResponse = await fetch(`${REDIS_HTTP_URL}/INCR/${token}`);
if (!tokenCountResponse.ok) {
- // eslint-disable-next-line no-console -- need for debugging
- console.error("Failed to increment token count in Redis", tokenCountResponse);
+ logger.error({ tokenCountResponse }, "Failed to increment token count in Redis");
return;
}
diff --git a/apps/web/app/sentry/SentryProvider.test.tsx b/apps/web/app/sentry/SentryProvider.test.tsx
new file mode 100644
index 0000000000..40b58e7165
--- /dev/null
+++ b/apps/web/app/sentry/SentryProvider.test.tsx
@@ -0,0 +1,101 @@
+import * as Sentry from "@sentry/nextjs";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SentryProvider } from "./SentryProvider";
+
+vi.mock("@sentry/nextjs", async () => {
+ const actual = await vi.importActual("@sentry/nextjs");
+ return {
+ ...actual,
+ replayIntegration: (options: any) => {
+ return {
+ name: "Replay",
+ id: "Replay",
+ options,
+ };
+ },
+ };
+});
+
+describe("SentryProvider", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("calls Sentry.init when sentryDsn is provided", () => {
+ const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
+ const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
+
+ render(
+
+ Test Content
+
+ );
+
+ // The useEffect runs after mount, so Sentry.init should have been called.
+ expect(initSpy).toHaveBeenCalled();
+ expect(initSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dsn: sentryDsn,
+ tracesSampleRate: 1,
+ debug: false,
+ replaysOnErrorSampleRate: 1.0,
+ replaysSessionSampleRate: 0.1,
+ integrations: expect.any(Array),
+ beforeSend: expect.any(Function),
+ })
+ );
+ });
+
+ it("does not call Sentry.init when sentryDsn is not provided", () => {
+ const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
+
+ render(
+
+ Test Content
+
+ );
+
+ expect(initSpy).not.toHaveBeenCalled();
+ });
+
+ it("renders children", () => {
+ const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
+ render(
+
+ Test Content
+
+ );
+ expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
+ });
+
+ it("processes beforeSend correctly", () => {
+ const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
+ const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
+
+ render(
+
+ Test Content
+
+ );
+
+ const config = initSpy.mock.calls[0][0];
+ expect(config).toHaveProperty("beforeSend");
+ const beforeSend = config.beforeSend;
+
+ if (!beforeSend) {
+ throw new Error("beforeSend is not defined");
+ }
+
+ const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
+
+ const hintWithNextNotFound = { originalException: { digest: "NEXT_NOT_FOUND" } };
+ expect(beforeSend(dummyEvent, hintWithNextNotFound)).toBeNull();
+
+ const hintWithOtherError = { originalException: { digest: "OTHER_ERROR" } };
+ expect(beforeSend(dummyEvent, hintWithOtherError)).toEqual(dummyEvent);
+
+ const hintWithoutError = { originalException: undefined };
+ expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
+ });
+});
diff --git a/apps/web/app/sentry/SentryProvider.tsx b/apps/web/app/sentry/SentryProvider.tsx
new file mode 100644
index 0000000000..b01e71dfc4
--- /dev/null
+++ b/apps/web/app/sentry/SentryProvider.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import * as Sentry from "@sentry/nextjs";
+import { useEffect } from "react";
+
+interface SentryProviderProps {
+ children: React.ReactNode;
+ sentryDsn?: string;
+}
+
+export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) => {
+ useEffect(() => {
+ if (sentryDsn) {
+ Sentry.init({
+ dsn: sentryDsn,
+
+ // Adjust this value in production, or use tracesSampler for greater control
+ tracesSampleRate: 1,
+
+ // Setting this option to true will print useful information to the console while you're setting up Sentry.
+ debug: false,
+
+ replaysOnErrorSampleRate: 1.0,
+
+ // This sets the sample rate to be 10%. You may want this to be 100% while
+ // in development and sample at a lower rate in production
+ replaysSessionSampleRate: 0.1,
+
+ // You can remove this option if you're not planning to use the Sentry Session Replay feature:
+ integrations: [
+ Sentry.replayIntegration({
+ // Additional Replay configuration goes in here, for example:
+ maskAllText: true,
+ blockAllMedia: true,
+ }),
+ ],
+
+ beforeSend(event, hint) {
+ const error = hint.originalException as Error;
+
+ // @ts-expect-error
+ if (error && error.digest === "NEXT_NOT_FOUND") {
+ return null;
+ }
+
+ return event;
+ },
+ });
+ }
+ }, []);
+
+ return <>{children}>;
+};
diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts
index 73be4632f7..cfdebe5bbb 100644
--- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts
+++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts
@@ -19,7 +19,7 @@ export const getFile = async (
headers: {
"Content-Type": metaData.contentType,
"Content-Disposition": "attachment",
- "Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
+ "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
Vary: "Accept-Encoding",
},
});
@@ -35,10 +35,7 @@ export const getFile = async (
status: 302,
headers: {
Location: signedUrl,
- "Cache-Control":
- accessType === "public"
- ? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
- : `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
+ "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
} catch (error: unknown) {
diff --git a/apps/web/cache-handler.mjs b/apps/web/cache-handler.mjs
index 5292d88e97..1065fa3b83 100644
--- a/apps/web/cache-handler.mjs
+++ b/apps/web/cache-handler.mjs
@@ -57,8 +57,6 @@ CacheHandler.onCreation(async () => {
timeoutMs: 1000,
};
- redisHandlerOptions.ttl = Number(process.env.REDIS_DEFAULT_TTL) || 86400; // 1 day
-
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler(redisHandlerOptions);
} else {
@@ -70,6 +68,11 @@ CacheHandler.onCreation(async () => {
return {
handlers: [handler],
+ ttl: {
+ // We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
+ defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
+ estimateExpireAge: (staleAge) => staleAge,
+ },
};
});
diff --git a/apps/web/instrumentation-node.ts b/apps/web/instrumentation-node.ts
index a1abee1ca5..55eeac233f 100644
--- a/apps/web/instrumentation-node.ts
+++ b/apps/web/instrumentation-node.ts
@@ -13,6 +13,7 @@ import {
} from "@opentelemetry/resources";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { env } from "@formbricks/lib/env";
+import { logger } from "@formbricks/logger";
const exporter = new PrometheusExporter({
port: env.PROMETHEUS_EXPORTER_PORT ? parseInt(env.PROMETHEUS_EXPORTER_PORT) : 9464,
@@ -51,7 +52,7 @@ process.on("SIGTERM", async () => {
await meterProvider.shutdown();
// Possibly close other instrumentation resources
} catch (e) {
- console.error("Error during graceful shutdown:", e);
+ logger.error(e, "Error during graceful shutdown");
} finally {
process.exit(0);
}
diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts
index 0b527429c2..e86284efd3 100644
--- a/apps/web/instrumentation.ts
+++ b/apps/web/instrumentation.ts
@@ -1,8 +1,14 @@
-import { env } from "@formbricks/lib/env";
+import { PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants";
// instrumentation.ts
export const register = async () => {
- if (process.env.NEXT_RUNTIME === "nodejs" && env.PROMETHEUS_ENABLED) {
+ if (process.env.NEXT_RUNTIME === "nodejs" && PROMETHEUS_ENABLED) {
await import("./instrumentation-node");
}
+ if (process.env.NEXT_RUNTIME === "nodejs" && SENTRY_DSN) {
+ await import("./sentry.server.config");
+ }
+ if (process.env.NEXT_RUNTIME === "edge" && SENTRY_DSN) {
+ await import("./sentry.edge.config");
+ }
};
diff --git a/apps/web/lib/utils/action-client.ts b/apps/web/lib/utils/action-client.ts
index 97d073fa47..ba73be13de 100644
--- a/apps/web/lib/utils/action-client.ts
+++ b/apps/web/lib/utils/action-client.ts
@@ -2,6 +2,7 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { getUser } from "@formbricks/lib/user/service";
+import { logger } from "@formbricks/logger";
import {
AuthenticationError,
AuthorizationError,
@@ -25,7 +26,7 @@ export const actionClient = createSafeActionClient({
}
// eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors
- console.error("SERVER ERROR: ", e);
+ logger.error(e, "SERVER ERROR");
return DEFAULT_SERVER_ERROR_MESSAGE;
},
});
diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts
index 080beca153..e79837acc8 100644
--- a/apps/web/middleware.ts
+++ b/apps/web/middleware.ts
@@ -24,15 +24,27 @@ import { ipAddress } from "@vercel/functions";
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
-import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
+import {
+ E2E_TESTING,
+ IS_PRODUCTION,
+ RATE_LIMITING_DISABLED,
+ SURVEY_URL,
+ WEBAPP_URL,
+} from "@formbricks/lib/constants";
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
+import { logger } from "@formbricks/logger";
const enforceHttps = (request: NextRequest): Response | null => {
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
const apiError: ApiErrorResponseV2 = {
type: "forbidden",
- details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
+ details: [
+ {
+ field: "",
+ issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.",
+ },
+ ],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 403 });
@@ -78,15 +90,44 @@ const applyRateLimiting = (request: NextRequest, ip: string) => {
}
};
+const handleSurveyDomain = (request: NextRequest): Response | null => {
+ try {
+ if (!SURVEY_URL) return null;
+
+ const host = request.headers.get("host") || "";
+ const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : "";
+ if (host !== surveyDomain) return null;
+
+ return new NextResponse(null, { status: 404 });
+ } catch (error) {
+ logger.error(error, "Error handling survey domain");
+ return new NextResponse(null, { status: 404 });
+ }
+};
+
+const isSurveyRoute = (request: NextRequest) => {
+ return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/");
+};
+
export const middleware = async (originalRequest: NextRequest) => {
+ if (isSurveyRoute(originalRequest)) {
+ return NextResponse.next();
+ }
+
+ // Handle survey domain routing.
+ const surveyResponse = handleSurveyDomain(originalRequest);
+ if (surveyResponse) return surveyResponse;
+
// Create a new Request object to override headers and add a unique request ID header
const request = new NextRequest(originalRequest, {
headers: new Headers(originalRequest.headers),
});
request.headers.set("x-request-id", uuidv4());
+ request.headers.set("x-start-time", Date.now().toString());
// Create a new NextResponse object to forward the new request with headers
+
const nextResponseWithCustomHeader = NextResponse.next({
request: {
headers: request.headers,
@@ -131,20 +172,6 @@ export const middleware = async (originalRequest: NextRequest) => {
export const config = {
matcher: [
- "/api/auth/callback/credentials",
- "/api/(.*)/client/:path*",
- "/api/v1/js/actions",
- "/api/v1/client/storage",
- "/share/(.*)/:path",
- "/environments/:path*",
- "/setup/organization/:path*",
- "/api/auth/signout",
- "/auth/login",
- "/auth/signup",
- "/api/packages/:path*",
- "/auth/verification-requested",
- "/auth/forgot-password",
- "/api/v1/management/:path*",
- "/api/v2/management/:path*",
+ "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware
],
};
diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx
index 26422883c0..5408dc4f8a 100644
--- a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx
+++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx
@@ -15,7 +15,7 @@ import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
interface ShareSurveyLinkProps {
survey: TSurvey;
- webAppUrl: string;
+ surveyDomain: string;
surveyUrl: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
@@ -23,8 +23,8 @@ interface ShareSurveyLinkProps {
export const ShareSurveyLink = ({
survey,
- webAppUrl,
surveyUrl,
+ surveyDomain,
setSurveyUrl,
locale,
}: ShareSurveyLinkProps) => {
@@ -32,7 +32,7 @@ export const ShareSurveyLink = ({
const [language, setLanguage] = useState("default");
const getUrl = useCallback(async () => {
- let url = `${webAppUrl}/s/${survey.id}`;
+ let url = `${surveyDomain}/s/${survey.id}`;
const queryParams: string[] = [];
if (survey.singleUse?.enabled) {
@@ -58,7 +58,9 @@ export const ShareSurveyLink = ({
}
setSurveyUrl(url);
- }, [survey, webAppUrl, language]);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [survey, surveyDomain, language]);
const generateNewSingleUseLink = () => {
getUrl();
diff --git a/apps/web/modules/api/v2/lib/rate-limit.ts b/apps/web/modules/api/v2/lib/rate-limit.ts
index 2747b447b6..2ca3d695eb 100644
--- a/apps/web/modules/api/v2/lib/rate-limit.ts
+++ b/apps/web/modules/api/v2/lib/rate-limit.ts
@@ -1,6 +1,7 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@formbricks/lib/constants";
+import { logger } from "@formbricks/logger";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export type RateLimitHelper = {
@@ -18,7 +19,7 @@ let warningDisplayed = false;
/** Prevent flooding the logs while testing/building */
function logOnce(message: string) {
if (warningDisplayed) return;
- console.warn(message);
+ logger.warn(message);
warningDisplayed = true;
}
diff --git a/apps/web/modules/api/v2/lib/response.ts b/apps/web/modules/api/v2/lib/response.ts
index 7eeeea0162..e3dc4c03f9 100644
--- a/apps/web/modules/api/v2/lib/response.ts
+++ b/apps/web/modules/api/v2/lib/response.ts
@@ -257,6 +257,34 @@ const successResponse = ({
);
};
+export const multiStatusResponse = ({
+ data,
+ meta,
+ cors = false,
+ cache = "private, no-store",
+}: {
+ data: Object;
+ meta?: Record;
+ cors?: boolean;
+ cache?: string;
+}) => {
+ const headers = {
+ ...(cors && corsHeaders),
+ "Cache-Control": cache,
+ };
+
+ return Response.json(
+ {
+ data,
+ meta,
+ } as ApiSuccessResponse,
+ {
+ status: 207,
+ headers,
+ }
+ );
+};
+
export const responses = {
badRequestResponse,
unauthorizedResponse,
@@ -267,4 +295,5 @@ export const responses = {
tooManyRequestsResponse,
internalServerErrorResponse,
successResponse,
+ multiStatusResponse,
};
diff --git a/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts b/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
index 7048ee1aa1..323854abc3 100644
--- a/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
+++ b/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
@@ -1,4 +1,11 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
+import { logger } from "@formbricks/logger";
+
+vi.mock("@formbricks/logger", () => ({
+ logger: {
+ warn: vi.fn(),
+ },
+}));
vi.mock("@unkey/ratelimit", () => ({
Ratelimit: vi.fn(),
@@ -16,18 +23,18 @@ describe("when rate limiting is disabled", () => {
});
test("should log a warning once and return a stubbed response", async () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const res1 = await rateLimiter()({ identifier: "test-id" });
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
- expect(warnSpy).toHaveBeenCalledWith("Rate limiting disabled");
+ expect(loggerSpy).toHaveBeenCalled();
// Subsequent calls won't log again.
await rateLimiter()({ identifier: "another-id" });
- expect(warnSpy).toHaveBeenCalledTimes(1);
- warnSpy.mockRestore();
+ expect(loggerSpy).toHaveBeenCalledTimes(1);
+ loggerSpy.mockRestore();
});
});
@@ -44,14 +51,14 @@ describe("when UNKEY_ROOT_KEY is missing", () => {
});
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "test-id" });
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
- expect(warnSpy).toHaveBeenCalledWith("Disabled due to not finding UNKEY_ROOT_KEY env variable");
- warnSpy.mockRestore();
+ expect(loggerSpy).toHaveBeenCalled();
+ loggerSpy.mockRestore();
});
});
diff --git a/apps/web/modules/api/v2/lib/tests/utils.test.ts b/apps/web/modules/api/v2/lib/tests/utils.test.ts
index ebcb82a9b8..0885a565cd 100644
--- a/apps/web/modules/api/v2/lib/tests/utils.test.ts
+++ b/apps/web/modules/api/v2/lib/tests/utils.test.ts
@@ -1,6 +1,7 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
+import { logger } from "@formbricks/logger";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
@@ -128,38 +129,77 @@ describe("utils", () => {
describe("logApiRequest", () => {
test("logs API request details", () => {
- const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+ // Mock the withContext method and its returned info method
+ const infoMock = vi.fn();
+ const withContextMock = vi.fn().mockReturnValue({
+ info: infoMock,
+ });
+
+ // Replace the original withContext with our mock
+ const originalWithContext = logger.withContext;
+ logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc&safeParam=value");
mockRequest.headers.set("x-request-id", "123");
+ mockRequest.headers.set("x-start-time", Date.now().toString());
- logApiRequest(mockRequest, 200, 100);
+ logApiRequest(mockRequest, 200);
- expect(consoleLogSpy).toHaveBeenCalledWith(
- `[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n correlationId: 123\n queryParams: {"safeParam":"value"}`
- );
+ // Verify withContext was called
+ expect(withContextMock).toHaveBeenCalled();
+ // Verify info was called on the child logger
+ expect(infoMock).toHaveBeenCalledWith("API Request Details");
- consoleLogSpy.mockRestore();
+ // Restore the original method
+ logger.withContext = originalWithContext;
});
test("logs API request details without correlationId and without safe query params", () => {
- const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+ // Mock the withContext method and its returned info method
+ const infoMock = vi.fn();
+ const withContextMock = vi.fn().mockReturnValue({
+ info: infoMock,
+ });
+
+ // Replace the original withContext with our mock
+ const originalWithContext = logger.withContext;
+ logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc");
mockRequest.headers.delete("x-request-id");
+ mockRequest.headers.set("x-start-time", (Date.now() - 100).toString());
- logApiRequest(mockRequest, 200, 100);
- expect(consoleLogSpy).toHaveBeenCalledWith(
- `[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n queryParams: {}`
+ logApiRequest(mockRequest, 200);
+
+ // Verify withContext was called with the expected context
+ expect(withContextMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: "GET",
+ path: "/api/test",
+ responseStatus: 200,
+ queryParams: {},
+ })
);
- consoleLogSpy.mockRestore();
+ // Verify info was called on the child logger
+ expect(infoMock).toHaveBeenCalledWith("API Request Details");
+
+ // Restore the original method
+ logger.withContext = originalWithContext;
});
});
describe("logApiError", () => {
test("logs API error details", () => {
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ // Mock the withContext method and its returned error method
+ const errorMock = vi.fn();
+ const withContextMock = vi.fn().mockReturnValue({
+ error: errorMock,
+ });
+
+ // Replace the original withContext with our mock
+ const originalWithContext = logger.withContext;
+ logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
@@ -171,15 +211,29 @@ describe("utils", () => {
logApiError(mockRequest, error);
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- `[API ERROR DETAILS]\n correlationId: 123\n error: ${JSON.stringify(error, null, 2)}`
- );
+ // Verify withContext was called with the expected context
+ expect(withContextMock).toHaveBeenCalledWith({
+ correlationId: "123",
+ error,
+ });
- consoleErrorSpy.mockRestore();
+ // Verify error was called on the child logger
+ expect(errorMock).toHaveBeenCalledWith("API Error Details");
+
+ // Restore the original method
+ logger.withContext = originalWithContext;
});
test("logs API error details without correlationId", () => {
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ // Mock the withContext method and its returned error method
+ const errorMock = vi.fn();
+ const withContextMock = vi.fn().mockReturnValue({
+ error: errorMock,
+ });
+
+ // Replace the original withContext with our mock
+ const originalWithContext = logger.withContext;
+ logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.delete("x-request-id");
@@ -191,11 +245,17 @@ describe("utils", () => {
logApiError(mockRequest, error);
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- `[API ERROR DETAILS]\n error: ${JSON.stringify(error, null, 2)}`
- );
+ // Verify withContext was called with the expected context
+ expect(withContextMock).toHaveBeenCalledWith({
+ correlationId: "",
+ error,
+ });
- consoleErrorSpy.mockRestore();
+ // Verify error was called on the child logger
+ expect(errorMock).toHaveBeenCalledWith("API Error Details");
+
+ // Restore the original method
+ logger.withContext = originalWithContext;
});
});
});
diff --git a/apps/web/modules/api/v2/lib/utils.ts b/apps/web/modules/api/v2/lib/utils.ts
index 80f60e06a6..92c9dbe9cc 100644
--- a/apps/web/modules/api/v2/lib/utils.ts
+++ b/apps/web/modules/api/v2/lib/utils.ts
@@ -1,6 +1,7 @@
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
-import { ZodError } from "zod";
+import { ZodCustomIssue, ZodIssue } from "zod";
+import { logger } from "@formbricks/logger";
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
logApiError(request, err);
@@ -33,18 +34,24 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
}
};
-export const formatZodError = (error: ZodError) => {
- return error.issues.map((issue) => ({
- field: issue.path.join("."),
- issue: issue.message,
- }));
+export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] }) => {
+ return error.issues.map((issue) => {
+ const issueParams = issue.code === "custom" ? issue.params : undefined;
+
+ return {
+ field: issue.path.join("."),
+ issue: issue.message ?? "An error occurred while processing your request. Please try again later.",
+ ...(issueParams && { meta: issueParams }),
+ };
+ });
};
-export const logApiRequest = (request: Request, responseStatus: number, duration: number): void => {
+export const logApiRequest = (request: Request, responseStatus: number): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
const correlationId = request.headers.get("x-request-id") || "";
+ const startTime = request.headers.get("x-start-time") || "";
const queryParams = Object.fromEntries(url.searchParams.entries());
const sensitiveParams = ["apikey", "token", "secret"];
@@ -52,14 +59,25 @@ export const logApiRequest = (request: Request, responseStatus: number, duration
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
);
- console.log(
- `[API REQUEST DETAILS] ${method} ${path} - ${responseStatus} - ${duration}ms${correlationId ? `\n correlationId: ${correlationId}` : ""}\n queryParams: ${JSON.stringify(safeQueryParams)}`
- );
+ // Info: Conveys general, operational messages about system progress and state.
+ logger
+ .withContext({
+ method,
+ path,
+ responseStatus,
+ duration: `${Date.now() - parseInt(startTime)} ms`,
+ correlationId,
+ queryParams: safeQueryParams,
+ })
+ .info("API Request Details");
};
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") || "";
- console.error(
- `[API ERROR DETAILS]${correlationId ? `\n correlationId: ${correlationId}` : ""}\n error: ${JSON.stringify(error, null, 2)}`
- );
+ logger
+ .withContext({
+ correlationId,
+ error,
+ })
+ .error("API Error Details");
};
diff --git a/apps/web/modules/api/v2/management/auth/api-wrapper.ts b/apps/web/modules/api/v2/management/auth/api-wrapper.ts
index 16862ce1b2..1a4cc8d1c8 100644
--- a/apps/web/modules/api/v2/management/auth/api-wrapper.ts
+++ b/apps/web/modules/api/v2/management/auth/api-wrapper.ts
@@ -2,7 +2,6 @@ import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { ZodRawShape, z } from "zod";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
-import { err } from "@formbricks/types/error-handlers";
import { authenticateRequest } from "./authenticate-request";
export type HandlerFn> = ({
@@ -41,66 +40,63 @@ export const apiWrapper = async ({
rateLimit?: boolean;
handler: HandlerFn>;
}): Promise => {
- try {
- const authentication = await authenticateRequest(request);
- if (!authentication.ok) throw authentication.error;
-
- let parsedInput: ParsedSchemas = {} as ParsedSchemas;
-
- if (schemas?.body) {
- const bodyData = await request.json();
- const bodyResult = schemas.body.safeParse(bodyData);
-
- if (!bodyResult.success) {
- throw err({
- type: "forbidden",
- details: formatZodError(bodyResult.error),
- });
- }
- parsedInput.body = bodyResult.data as ParsedSchemas["body"];
- }
-
- if (schemas?.query) {
- const url = new URL(request.url);
- const queryObject = Object.fromEntries(url.searchParams.entries());
- const queryResult = schemas.query.safeParse(queryObject);
- if (!queryResult.success) {
- throw err({
- type: "unprocessable_entity",
- details: formatZodError(queryResult.error),
- });
- }
- parsedInput.query = queryResult.data as ParsedSchemas["query"];
- }
-
- if (schemas?.params) {
- const paramsObject = (await externalParams) || {};
- console.log("paramsObject: ", paramsObject);
- const paramsResult = schemas.params.safeParse(paramsObject);
- if (!paramsResult.success) {
- throw err({
- type: "unprocessable_entity",
- details: formatZodError(paramsResult.error),
- });
- }
- parsedInput.params = paramsResult.data as ParsedSchemas["params"];
- }
-
- if (rateLimit) {
- const rateLimitResponse = await checkRateLimitAndThrowError({
- identifier: authentication.data.hashedApiKey,
- });
- if (!rateLimitResponse.ok) {
- throw rateLimitResponse.error;
- }
- }
-
- return handler({
- authentication: authentication.data,
- parsedInput,
- request,
- });
- } catch (err) {
- return handleApiError(request, err);
+ const authentication = await authenticateRequest(request);
+ if (!authentication.ok) {
+ return handleApiError(request, authentication.error);
}
+
+ let parsedInput: ParsedSchemas = {} as ParsedSchemas;
+
+ if (schemas?.body) {
+ const bodyData = await request.json();
+ const bodyResult = schemas.body.safeParse(bodyData);
+
+ if (!bodyResult.success) {
+ return handleApiError(request, {
+ type: "unprocessable_entity",
+ details: formatZodError(bodyResult.error),
+ });
+ }
+ parsedInput.body = bodyResult.data as ParsedSchemas["body"];
+ }
+
+ if (schemas?.query) {
+ const url = new URL(request.url);
+ const queryObject = Object.fromEntries(url.searchParams.entries());
+ const queryResult = schemas.query.safeParse(queryObject);
+ if (!queryResult.success) {
+ return handleApiError(request, {
+ type: "unprocessable_entity",
+ details: formatZodError(queryResult.error),
+ });
+ }
+ parsedInput.query = queryResult.data as ParsedSchemas["query"];
+ }
+
+ if (schemas?.params) {
+ const paramsObject = (await externalParams) || {};
+ const paramsResult = schemas.params.safeParse(paramsObject);
+ if (!paramsResult.success) {
+ return handleApiError(request, {
+ type: "unprocessable_entity",
+ details: formatZodError(paramsResult.error),
+ });
+ }
+ parsedInput.params = paramsResult.data as ParsedSchemas["params"];
+ }
+
+ if (rateLimit) {
+ const rateLimitResponse = await checkRateLimitAndThrowError({
+ identifier: authentication.data.hashedApiKey,
+ });
+ if (!rateLimitResponse.ok) {
+ return handleApiError(request, rateLimitResponse.error);
+ }
+ }
+
+ return handler({
+ authentication: authentication.data,
+ parsedInput,
+ request,
+ });
};
diff --git a/apps/web/modules/api/v2/management/auth/authenticate-request.ts b/apps/web/modules/api/v2/management/auth/authenticate-request.ts
index 7e6a1cacde..f0ec9e3165 100644
--- a/apps/web/modules/api/v2/management/auth/authenticate-request.ts
+++ b/apps/web/modules/api/v2/management/auth/authenticate-request.ts
@@ -8,6 +8,7 @@ export const authenticateRequest = async (
request: Request
): Promise> => {
const apiKey = request.headers.get("x-api-key");
+
if (apiKey) {
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentIdResult.ok) {
diff --git a/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts b/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts
index 9971582f32..7a14151732 100644
--- a/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts
+++ b/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts
@@ -1,4 +1,5 @@
-import { logApiRequest } from "@/modules/api/v2/lib/utils";
+import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
+import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
export const authenticatedApiClient = async ({
@@ -14,19 +15,28 @@ export const authenticatedApiClient = async ({
rateLimit?: boolean;
handler: HandlerFn>;
}): Promise => {
- const startTime = Date.now();
+ try {
+ const response = await apiWrapper({
+ request,
+ schemas,
+ externalParams,
+ rateLimit,
+ handler,
+ });
- const response = await apiWrapper({
- request,
- schemas,
- externalParams,
- rateLimit,
- handler,
- });
+ if (response.ok) {
+ logApiRequest(request, response.status);
+ }
- const duration = Date.now() - startTime;
+ return response;
+ } catch (err) {
+ if ("type" in err) {
+ return handleApiError(request, err as ApiErrorResponseV2);
+ }
- logApiRequest(request, response.status, duration);
-
- return response;
+ return handleApiError(request, {
+ type: "internal_server_error",
+ details: [{ field: "error", issue: "An error occurred while processing your request." }],
+ });
+ }
};
diff --git a/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts b/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts
index ac89f211c4..33e2a17145 100644
--- a/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts
+++ b/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts
@@ -19,6 +19,11 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
}));
+vi.mock("@/modules/api/v2/lib/utils", () => ({
+ formatZodError: vi.fn(),
+ handleApiError: vi.fn(),
+}));
+
describe("apiWrapper", () => {
it("should handle request and return response", async () => {
const request = new Request("http://localhost", {
diff --git a/apps/web/modules/api/v2/management/lib/helper.ts b/apps/web/modules/api/v2/management/lib/helper.ts
index 0b5d07e406..0e86d002e1 100644
--- a/apps/web/modules/api/v2/management/lib/helper.ts
+++ b/apps/web/modules/api/v2/management/lib/helper.ts
@@ -1,4 +1,7 @@
-import { fetchEnvironmentId } from "@/modules/api/v2/management/lib/services";
+import {
+ fetchEnvironmentId,
+ fetchEnvironmentIdFromSurveyIds,
+} from "@/modules/api/v2/management/lib/services";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Result, ok } from "@formbricks/types/error-handlers";
@@ -14,3 +17,31 @@ export const getEnvironmentId = async (
return ok(result.data.environmentId);
};
+
+/**
+ * Validates that all surveys are in the same environment and return the environment id
+ * @param surveyIds array of survey ids from the same environment
+ * @returns the common environment id
+ */
+export const getEnvironmentIdFromSurveyIds = async (
+ surveyIds: string[]
+): Promise> => {
+ const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
+
+ if (!result.ok) {
+ return result;
+ }
+
+ // Check if all items in the array are the same
+ if (new Set(result.data).size !== 1) {
+ return {
+ ok: false,
+ error: {
+ type: "bad_request",
+ details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
+ },
+ };
+ }
+
+ return ok(result.data[0]);
+};
diff --git a/apps/web/modules/api/v2/management/lib/openapi.ts b/apps/web/modules/api/v2/management/lib/openapi.ts
deleted file mode 100644
index f268bb2516..0000000000
--- a/apps/web/modules/api/v2/management/lib/openapi.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {
- deleteResponseEndpoint,
- getResponseEndpoint,
- updateResponseEndpoint,
-} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
-import {
- createResponseEndpoint,
- getResponsesEndpoint,
-} from "@/modules/api/v2/management/responses/lib/openapi";
-import { ZodOpenApiPathsObject } from "zod-openapi";
-
-export const responsePaths: ZodOpenApiPathsObject = {
- "/responses": {
- get: getResponsesEndpoint,
- post: createResponseEndpoint,
- },
- "/responses/{id}": {
- get: getResponseEndpoint,
- put: updateResponseEndpoint,
- delete: deleteResponseEndpoint,
- },
-};
diff --git a/apps/web/modules/api/v2/management/lib/services.ts b/apps/web/modules/api/v2/management/lib/services.ts
index 1d1a769104..9420165725 100644
--- a/apps/web/modules/api/v2/management/lib/services.ts
+++ b/apps/web/modules/api/v2/management/lib/services.ts
@@ -41,3 +41,36 @@ export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: bo
}
)()
);
+
+export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) =>
+ cache(
+ async (): Promise> => {
+ try {
+ const results = await prisma.survey.findMany({
+ where: { id: { in: surveyIds } },
+ select: {
+ environmentId: true,
+ },
+ });
+
+ if (results.length !== surveyIds.length) {
+ return err({
+ type: "not_found",
+ details: [{ field: "survey", issue: "not found" }],
+ });
+ }
+
+ return ok(results.map((result) => result.environmentId));
+ } catch (error) {
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "survey", issue: error.message }],
+ });
+ }
+ },
+ [`services-fetchEnvironmentIdFromSurveyIds-${surveyIds.join("-")}`],
+ {
+ tags: surveyIds.map((surveyId) => surveyCache.tag.byId(surveyId)),
+ }
+ )()
+);
diff --git a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts
index 5b76f2360b..845c61cd15 100644
--- a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts
+++ b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts
@@ -1,14 +1,17 @@
+import { fetchEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/services";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
+import { createId } from "@paralleldrive/cuid2";
import { describe, expect, it, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
-import { getEnvironmentId } from "../helper";
+import { getEnvironmentId, getEnvironmentIdFromSurveyIds } from "../helper";
import { fetchEnvironmentId } from "../services";
vi.mock("../services", () => ({
fetchEnvironmentId: vi.fn(),
+ fetchEnvironmentIdFromSurveyIds: vi.fn(),
}));
-describe("Helper Functions", () => {
+describe("Tests for getEnvironmentId", () => {
it("should return environmentId for surveyId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
@@ -41,3 +44,42 @@ describe("Helper Functions", () => {
}
});
});
+
+describe("getEnvironmentIdFromSurveyIds", () => {
+ const envId1 = createId();
+ const envId2 = createId();
+
+ it("returns the common environment id when all survey ids are in the same environment", async () => {
+ vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
+ ok: true,
+ data: [envId1, envId1],
+ });
+ const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
+ expect(result).toEqual(ok(envId1));
+ });
+
+ it("returns error when surveys are not in the same environment", async () => {
+ vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
+ ok: true,
+ data: [envId1, envId2],
+ });
+ const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toEqual({
+ type: "bad_request",
+ details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
+ });
+ }
+ });
+
+ it("returns error when API call fails", async () => {
+ const apiError = {
+ type: "server_error",
+ details: [{ field: "api", issue: "failed" }],
+ } as unknown as ApiErrorResponseV2;
+ vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({ ok: false, error: apiError });
+ const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
+ expect(result).toEqual({ ok: false, error: apiError });
+ });
+});
diff --git a/apps/web/modules/api/v2/management/lib/tests/services.test.ts b/apps/web/modules/api/v2/management/lib/tests/services.test.ts
index 9e22295f7a..02af5f1406 100644
--- a/apps/web/modules/api/v2/management/lib/tests/services.test.ts
+++ b/apps/web/modules/api/v2/management/lib/tests/services.test.ts
@@ -1,18 +1,17 @@
-import { beforeEach, describe, expect, test, vi } from "vitest";
+import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
-import { fetchEnvironmentId } from "../services";
+import { fetchEnvironmentId, fetchEnvironmentIdFromSurveyIds } from "../services";
vi.mock("@formbricks/database", () => ({
prisma: {
- survey: { findFirst: vi.fn() },
+ survey: {
+ findFirst: vi.fn(),
+ findMany: vi.fn(),
+ },
},
}));
describe("Services", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
describe("getSurveyAndEnvironmentId", () => {
test("should return surveyId and environmentId for responseId", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
@@ -80,4 +79,36 @@ describe("Services", () => {
}
});
});
+
+ describe("fetchEnvironmentIdFromSurveyIds", () => {
+ test("should return an array of environmentIds if all surveys exist", async () => {
+ vi.mocked(prisma.survey.findMany).mockResolvedValue([
+ { environmentId: "env-1" },
+ { environmentId: "env-2" },
+ ]);
+ const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toEqual(["env-1", "env-2"]);
+ }
+ });
+
+ test("should return not_found error if any survey is missing", async () => {
+ vi.mocked(prisma.survey.findMany).mockResolvedValue([{ environmentId: "env-1" }]);
+ const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.type).toBe("not_found");
+ }
+ });
+
+ test("should return internal_server_error if prisma query fails", async () => {
+ vi.mocked(prisma.survey.findMany).mockRejectedValue(new Error("Query failed"));
+ const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.type).toBe("internal_server_error");
+ }
+ });
+ });
});
diff --git a/apps/web/modules/api/v2/management/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts
index a748f15451..f189e4f76a 100644
--- a/apps/web/modules/api/v2/management/lib/tests/utils.test.ts
+++ b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts
@@ -1,5 +1,7 @@
+import { TGetFilter } from "@/modules/api/v2/types/api-filter";
+import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
-import { hashApiKey } from "../utils";
+import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
describe("hashApiKey", () => {
test("generate the correct sha256 hash for a given input", () => {
@@ -15,3 +17,72 @@ describe("hashApiKey", () => {
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
});
});
+
+describe("pickCommonFilter", () => {
+ test("picks the common filter fields correctly", () => {
+ const params = {
+ limit: 10,
+ skip: 5,
+ sortBy: "createdAt",
+ order: "asc",
+ startDate: new Date("2023-01-01"),
+ endDate: new Date("2023-12-31"),
+ } as TGetFilter;
+ const result = pickCommonFilter(params);
+ expect(result).toEqual(params);
+ });
+
+ test("handles missing fields gracefully", () => {
+ const params = { limit: 10 } as TGetFilter;
+ const result = pickCommonFilter(params);
+ expect(result).toEqual({
+ limit: 10,
+ skip: undefined,
+ sortBy: undefined,
+ order: undefined,
+ startDate: undefined,
+ endDate: undefined,
+ });
+ });
+
+ describe("buildCommonFilterQuery", () => {
+ test("applies startDate and endDate when provided", () => {
+ const query: Prisma.WebhookFindManyArgs = { where: {} };
+ const params = {
+ startDate: new Date("2023-01-01"),
+ endDate: new Date("2023-12-31"),
+ } as TGetFilter;
+ const result = buildCommonFilterQuery(query, params);
+ expect(result.where?.createdAt?.gte).toEqual(params.startDate);
+ expect(result.where?.createdAt?.lte).toEqual(params.endDate);
+ });
+
+ test("applies sortBy and order when provided", () => {
+ const query: Prisma.WebhookFindManyArgs = { where: {} };
+ const params = { sortBy: "createdAt", order: "desc" } as TGetFilter;
+ const result = buildCommonFilterQuery(query, params);
+ expect(result.orderBy).toEqual({ createdAt: "desc" });
+ });
+
+ test("applies limit (take) when provided", () => {
+ const query: Prisma.WebhookFindManyArgs = { where: {} };
+ const params = { limit: 5 } as TGetFilter;
+ const result = buildCommonFilterQuery(query, params);
+ expect(result.take).toBe(5);
+ });
+
+ test("applies skip when provided", () => {
+ const query: Prisma.WebhookFindManyArgs = { where: {} };
+ const params = { skip: 10 } as TGetFilter;
+ const result = buildCommonFilterQuery(query, params);
+ expect(result.skip).toBe(10);
+ });
+
+ test("handles missing fields gracefully", () => {
+ const query = {};
+ const params = {} as TGetFilter;
+ const result = buildCommonFilterQuery(query, params);
+ expect(result).toEqual({});
+ });
+ });
+});
diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts
index 0d8195da8a..3e601de2cc 100644
--- a/apps/web/modules/api/v2/management/lib/utils.ts
+++ b/apps/web/modules/api/v2/management/lib/utils.ts
@@ -1,3 +1,65 @@
+import { TGetFilter } from "@/modules/api/v2/types/api-filter";
+import { Prisma } from "@prisma/client";
import { createHash } from "crypto";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
+
+export function pickCommonFilter(params: T) {
+ const { limit, skip, sortBy, order, startDate, endDate } = params;
+ return { limit, skip, sortBy, order, startDate, endDate };
+}
+
+type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
+
+export function buildCommonFilterQuery(query: T, params: TGetFilter): T {
+ const { limit, skip, sortBy, order, startDate, endDate } = params || {};
+
+ let filteredQuery = {
+ ...query,
+ };
+
+ if (startDate) {
+ filteredQuery = {
+ ...filteredQuery,
+ where: {
+ ...filteredQuery.where,
+ createdAt: {
+ ...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
+ gte: startDate,
+ },
+ },
+ };
+ }
+
+ if (endDate) {
+ filteredQuery = {
+ ...filteredQuery,
+ where: {
+ ...filteredQuery.where,
+ createdAt: {
+ ...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
+ lte: endDate,
+ },
+ },
+ };
+ }
+
+ if (sortBy) {
+ filteredQuery = {
+ ...filteredQuery,
+ orderBy: {
+ [sortBy]: order,
+ },
+ };
+ }
+
+ if (limit) {
+ filteredQuery = { ...filteredQuery, take: limit };
+ }
+
+ if (skip) {
+ filteredQuery = { ...filteredQuery, skip };
+ }
+
+ return filteredQuery;
+}
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts
index a957d09e3b..b13245d343 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts
@@ -1,6 +1,7 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { prisma } from "@formbricks/database";
+import { PrismaErrorType } from "@formbricks/database/types/error";
import { displayCache } from "@formbricks/lib/display/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
@@ -26,7 +27,10 @@ export const deleteDisplay = async (displayId: string): Promise ({
@@ -39,7 +40,7 @@ describe("Display Lib", () => {
test("return a not_found error when the display is not found", async () => {
vi.mocked(prisma.display.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Display not found", {
- code: "P2025",
+ code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
cause: "Display not found",
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts
index b4a5717337..edd9fb78d6 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts
@@ -2,6 +2,7 @@ import { response, responseId, responseInput, survey } from "./__mocks__/respons
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
+import { PrismaErrorType } from "@formbricks/database/types/error";
import { ok, okVoid } from "@formbricks/types/error-handlers";
import { deleteDisplay } from "../display";
import { deleteResponse, getResponse, updateResponse } from "../response";
@@ -154,7 +155,7 @@ describe("Response Lib", () => {
test("handle prisma client error code P2025", async () => {
vi.mocked(prisma.response.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
- code: "P2025",
+ code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
cause: "Response not found",
@@ -192,7 +193,7 @@ describe("Response Lib", () => {
test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
- code: "P2025",
+ code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
cause: "Response not found",
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts
index a5e3ad6488..b1908799b8 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts
@@ -1,9 +1,16 @@
import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { deleteFile } from "@formbricks/lib/storage/service";
+import { logger } from "@formbricks/logger";
import { okVoid } from "@formbricks/types/error-handlers";
import { findAndDeleteUploadedFilesInResponse } from "../utils";
+vi.mock("@formbricks/logger", () => ({
+ logger: {
+ error: vi.fn(),
+ },
+}));
+
vi.mock("@formbricks/lib/storage/service", () => ({
deleteFile: vi.fn(),
}));
@@ -37,15 +44,15 @@ describe("findAndDeleteUploadedFilesInResponse", () => {
[fileUploadQuestion.id]: [invalidFileUrl],
};
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const loggerSpy = vi.spyOn(logger, "error");
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
expect(deleteFile).not.toHaveBeenCalled();
- expect(consoleErrorSpy).toHaveBeenCalled();
+ expect(loggerSpy).toHaveBeenCalled();
expect(result).toEqual(okVoid());
- consoleErrorSpy.mockRestore();
+ loggerSpy.mockRestore();
});
test("process multiple file URLs", async () => {
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts
index 005c9de21e..11655b2e09 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts
@@ -1,6 +1,7 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response, Survey } from "@prisma/client";
import { deleteFile } from "@formbricks/lib/storage/service";
+import { logger } from "@formbricks/logger";
import { Result, okVoid } from "@formbricks/types/error-handlers";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -26,7 +27,7 @@ export const findAndDeleteUploadedFilesInResponse = async (
}
return deleteFile(environmentId, accessType as "private" | "public", fileName);
} catch (error) {
- console.error(`Failed to delete file ${fileUrl}:`, error);
+ logger.error({ error, fileUrl }, "Failed to delete file");
}
});
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts
index 08a01513aa..90443a5202 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts
@@ -47,7 +47,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error);
}
- return responses.successResponse({ data: response.data });
+ return responses.successResponse(response);
},
});
@@ -88,7 +88,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
return handleApiError(request, response.error);
}
- return responses.successResponse({ data: response.data });
+ return responses.successResponse(response);
},
});
@@ -130,6 +130,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
return handleApiError(request, response.error);
}
- return responses.successResponse({ data: response.data });
+ return responses.successResponse(response);
},
});
diff --git a/apps/web/modules/api/v2/management/responses/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/lib/openapi.ts
index f562b1c3c6..e46da37627 100644
--- a/apps/web/modules/api/v2/management/responses/lib/openapi.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts
@@ -3,10 +3,11 @@ import {
getResponseEndpoint,
updateResponseEndpoint,
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
-import { ZGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
+import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
+import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
-import { ZResponse, ZResponseInput } from "@formbricks/types/responses";
+import { ZResponse } from "@formbricks/database/zod/responses";
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
operationId: "getResponses",
@@ -21,7 +22,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
description: "Responses retrieved successfully.",
content: {
"application/json": {
- schema: z.array(ZResponse),
+ schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
},
},
},
@@ -47,7 +48,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
description: "Response created successfully.",
content: {
"application/json": {
- schema: ZResponse,
+ schema: makePartialSchema(ZResponse),
},
},
},
diff --git a/apps/web/modules/api/v2/management/responses/lib/organization.ts b/apps/web/modules/api/v2/management/responses/lib/organization.ts
index 9ca2a06cef..334f892e02 100644
--- a/apps/web/modules/api/v2/management/responses/lib/organization.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/organization.ts
@@ -48,7 +48,7 @@ export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentI
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
cache(
- async (): Promise, ApiErrorResponseV2>> => {
+ async (): Promise> => {
try {
const organization = await prisma.organization.findFirst({
where: {
@@ -62,7 +62,8 @@ export const getOrganizationBilling = reactCache(async (organizationId: string)
if (!organization) {
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
}
- return ok(organization);
+
+ return ok(organization.billing);
} catch (error) {
return err({
type: "internal_server_error",
@@ -126,26 +127,27 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
cache(
async (): Promise> => {
try {
- const organization = await getOrganizationBilling(organizationId);
- if (!organization.ok) {
- return err(organization.error);
+ const billing = await getOrganizationBilling(organizationId);
+ if (!billing.ok) {
+ return err(billing.error);
}
// Determine the start date based on the plan type
let startDate: Date;
- if (organization.data.billing.plan === "free") {
+
+ if (billing.data.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
- if (!organization.data.billing.periodStart) {
+ if (!billing.data.periodStart) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
}
- startDate = organization.data.billing.periodStart;
+ startDate = billing.data.periodStart;
}
// Get all environment IDs for the organization
diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts
index 0c1ccf841a..6e0ce2516d 100644
--- a/apps/web/modules/api/v2/management/responses/lib/response.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/response.ts
@@ -16,6 +16,7 @@ import { responseCache } from "@formbricks/lib/response/cache";
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
+import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const createResponse = async (
@@ -40,7 +41,14 @@ export const createResponse = async (
} = responseInput;
try {
- const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
+ let ttc = {};
+ if (initialTtc) {
+ if (finished) {
+ ttc = calculateTtcTotal(initialTtc);
+ } else {
+ ttc = initialTtc;
+ }
+ }
const prismaData: Prisma.ResponseCreateInput = {
survey: {
@@ -66,11 +74,11 @@ export const createResponse = async (
return err(organizationIdResult.error);
}
- const organizationResult = await getOrganizationBilling(organizationIdResult.data);
- if (!organizationResult.ok) {
- return err(organizationResult.error);
+ const billing = await getOrganizationBilling(organizationIdResult.data);
+ if (!billing.ok) {
+ return err(billing.error);
}
- const organization = organizationResult.data;
+ const billingData = billing.data;
const response = await prisma.response.create({
data: prismaData,
@@ -94,12 +102,12 @@ export const createResponse = async (
}
const responsesCount = responsesCountResult.data;
- const responsesLimit = organization.billing.limits.monthly.responses;
+ const responsesLimit = billingData.limits?.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
- plan: organization.billing.plan,
+ plan: billingData.plan,
limits: {
projects: null,
monthly: {
@@ -110,7 +118,7 @@ export const createResponse = async (
});
} catch (err) {
// Log error but do not throw it
- console.error(`Error sending plan limits reached event to Posthog: ${err}`);
+ logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
}
diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts
index 3dc84295d0..d908a5d1b4 100644
--- a/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts
@@ -85,7 +85,7 @@ describe("Organization Lib", () => {
});
expect(result.ok).toBe(true);
if (result.ok) {
- expect(result.data.billing).toEqual(organizationBilling);
+ expect(result.data).toEqual(organizationBilling);
}
});
diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts
index d225af34a1..524749896c 100644
--- a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts
@@ -55,7 +55,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
- vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
+ vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInput);
@@ -70,7 +70,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
- vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
+ vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputNotFinished);
@@ -85,7 +85,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
- vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
+ vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputWithoutTtc);
@@ -100,7 +100,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
- vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
+ vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputWithoutDisplay);
@@ -145,7 +145,7 @@ describe("Response Lib", () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
- vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
+ vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
@@ -165,7 +165,7 @@ describe("Response Lib", () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
- vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
+ vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
err({ type: "internal_server_error", details: [{ field: "organization", issue: "Aggregate error" }] })
@@ -186,7 +186,7 @@ describe("Response Lib", () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
- vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
+ vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts
index 088c955350..6ee8be7731 100644
--- a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts
@@ -1,97 +1,40 @@
+import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
-import { describe, expect, test } from "vitest";
+import { Prisma } from "@prisma/client";
+import { describe, expect, it, vi } from "vitest";
import { getResponsesQuery } from "../utils";
+vi.mock("@/modules/api/v2/management/lib/utils", () => ({
+ pickCommonFilter: vi.fn(),
+ buildCommonFilterQuery: vi.fn(),
+}));
+
describe("getResponsesQuery", () => {
- const environmentId = "env_1";
- const filters: TGetResponsesFilter = {
- limit: 10,
- skip: 0,
- sortBy: "createdAt",
- order: "asc",
- };
-
- test("return the base query when no params are provided", () => {
- const query = getResponsesQuery(environmentId);
- expect(query).toEqual({
- where: {
- survey: { environmentId },
- },
- });
+ it("adds surveyId to where clause if provided", () => {
+ const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
+ expect(result?.where?.surveyId).toBe("survey123");
});
- test("add surveyId to the query when provided", () => {
- const query = getResponsesQuery(environmentId, { ...filters, surveyId: "survey_1" });
- expect(query.where).toEqual({
- survey: { environmentId },
- surveyId: "survey_1",
- });
+ it("adds contactId to where clause if provided", () => {
+ const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
+ expect(result?.where?.contactId).toBe("contact123");
});
- test("add startDate filter to the query", () => {
- const startDate = new Date("2023-01-01");
- const query = getResponsesQuery(environmentId, { ...filters, startDate });
- expect(query.where).toEqual({
- survey: { environmentId },
- createdAt: { gte: startDate },
- });
- });
+ it("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => {
+ vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
+ vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
- test("add endDate filter to the query", () => {
- const endDate = new Date("2023-01-31");
- const query = getResponsesQuery(environmentId, { ...filters, endDate });
- expect(query.where).toEqual({
- survey: { environmentId },
- createdAt: { lte: endDate },
- });
- });
-
- test("add sortBy and order to the query", () => {
- const query = getResponsesQuery(environmentId, { ...filters, sortBy: "createdAt", order: "desc" });
- expect(query.orderBy).toEqual({
- createdAt: "desc",
- });
- });
-
- test("add limit (take) to the query", () => {
- const query = getResponsesQuery(environmentId, { ...filters, limit: 10 });
- expect(query.take).toBe(10);
- });
-
- test("add skip to the query", () => {
- const query = getResponsesQuery(environmentId, { ...filters, skip: 5 });
- expect(query.skip).toBe(5);
- });
-
- test("add contactId to the query", () => {
- const query = getResponsesQuery(environmentId, { ...filters, contactId: "contact_1" });
- expect(query.where).toEqual({
- survey: { environmentId },
- contactId: "contact_1",
- });
- });
-
- test("combine multiple filters correctly", () => {
- const params = {
- ...filters,
- surveyId: "survey_1",
- startDate: new Date("2023-01-01"),
- endDate: new Date("2023-01-31"),
- limit: 20,
- skip: 10,
- contactId: "contact_1",
- };
- const query = getResponsesQuery(environmentId, params);
- expect(query.where).toEqual({
- survey: { environmentId },
- surveyId: "survey_1",
- createdAt: { lte: params.endDate, gte: params.startDate },
- contactId: "contact_1",
- });
- expect(query.orderBy).toEqual({
- createdAt: "asc",
- });
- expect(query.take).toBe(20);
- expect(query.skip).toBe(10);
+ const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
+ expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
+ expect(buildCommonFilterQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ survey: { environmentId: "env-id" },
+ surveyId: "test",
+ },
+ }),
+ { someFilter: true }
+ );
+ expect(result).toEqual({ where: { combined: true } });
});
});
diff --git a/apps/web/modules/api/v2/management/responses/lib/utils.ts b/apps/web/modules/api/v2/management/responses/lib/utils.ts
index 536022d508..5fa258311c 100644
--- a/apps/web/modules/api/v2/management/responses/lib/utils.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/utils.ts
@@ -1,9 +1,8 @@
+import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { Prisma } from "@prisma/client";
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
- const { surveyId, limit, skip, sortBy, order, startDate, endDate, contactId } = params || {};
-
let query: Prisma.ResponseFindManyArgs = {
where: {
survey: {
@@ -12,6 +11,10 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
},
};
+ if (!params) return query;
+
+ const { surveyId, contactId } = params || {};
+
if (surveyId) {
query = {
...query,
@@ -22,55 +25,6 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
};
}
- if (startDate) {
- query = {
- ...query,
- where: {
- ...query.where,
- createdAt: {
- ...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
- gte: startDate,
- },
- },
- };
- }
-
- if (endDate) {
- query = {
- ...query,
- where: {
- ...query.where,
- createdAt: {
- ...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
- lte: endDate,
- },
- },
- };
- }
-
- if (sortBy) {
- query = {
- ...query,
- orderBy: {
- [sortBy]: order,
- },
- };
- }
-
- if (limit) {
- query = {
- ...query,
- take: limit,
- };
- }
-
- if (skip) {
- query = {
- ...query,
- skip: skip,
- };
- }
-
if (contactId) {
query = {
...query,
@@ -81,5 +35,11 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
};
}
+ const baseFilter = pickCommonFilter(params);
+
+ if (baseFilter) {
+ query = buildCommonFilterQuery(query, baseFilter);
+ }
+
return query;
};
diff --git a/apps/web/modules/api/v2/management/responses/types/responses.ts b/apps/web/modules/api/v2/management/responses/types/responses.ts
index b2161aa953..96a1655929 100644
--- a/apps/web/modules/api/v2/management/responses/types/responses.ts
+++ b/apps/web/modules/api/v2/management/responses/types/responses.ts
@@ -1,28 +1,21 @@
+import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZResponse } from "@formbricks/database/zod/responses";
-export const ZGetResponsesFilter = z
- .object({
- limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
- skip: z.coerce.number().nonnegative().optional().default(0),
- sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
- order: z.enum(["asc", "desc"]).optional().default("desc"),
- startDate: z.coerce.date().optional(),
- endDate: z.coerce.date().optional(),
- surveyId: z.string().cuid2().optional(),
- contactId: z.string().optional(),
- })
- .refine(
- (data) => {
- if (data.startDate && data.endDate && data.startDate > data.endDate) {
- return false;
- }
- return true;
- },
- {
- message: "startDate must be before endDate",
+export const ZGetResponsesFilter = ZGetFilter.extend({
+ surveyId: z.string().cuid2().optional(),
+ contactId: z.string().optional(),
+}).refine(
+ (data) => {
+ if (data.startDate && data.endDate && data.startDate > data.endDate) {
+ return false;
}
- );
+ return true;
+ },
+ {
+ message: "startDate must be before endDate",
+ }
+);
export type TGetResponsesFilter = z.infer;
@@ -39,21 +32,16 @@ export const ZResponseInput = ZResponse.pick({
variables: true,
ttc: true,
meta: true,
-})
- .partial({
- displayId: true,
- singleUseId: true,
- endingId: true,
- language: true,
- variables: true,
- ttc: true,
- meta: true,
- createdAt: true,
- updatedAt: true,
- })
- .openapi({
- ref: "responseCreate",
- description: "A response to create",
- });
+}).partial({
+ displayId: true,
+ singleUseId: true,
+ endingId: true,
+ language: true,
+ variables: true,
+ ttc: true,
+ meta: true,
+ createdAt: true,
+ updatedAt: true,
+});
export type TResponseInput = z.infer;
diff --git a/apps/web/modules/api/v2/management/roles/lib/openapi.ts b/apps/web/modules/api/v2/management/roles/lib/openapi.ts
new file mode 100644
index 0000000000..e7e937a924
--- /dev/null
+++ b/apps/web/modules/api/v2/management/roles/lib/openapi.ts
@@ -0,0 +1,26 @@
+import { z } from "zod";
+import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
+
+export const getRolesEndpoint: ZodOpenApiOperationObject = {
+ operationId: "getRoles",
+ summary: "Get roles",
+ description: "Gets roles from the database.",
+ requestParams: {},
+ tags: ["Management API > Roles"],
+ responses: {
+ "200": {
+ description: "Roles retrieved successfully.",
+ content: {
+ "application/json": {
+ schema: z.array(z.string()),
+ },
+ },
+ },
+ },
+};
+
+export const rolePaths: ZodOpenApiPathsObject = {
+ "/roles": {
+ get: getRolesEndpoint,
+ },
+};
diff --git a/apps/web/modules/api/v2/management/roles/lib/roles.ts b/apps/web/modules/api/v2/management/roles/lib/roles.ts
new file mode 100644
index 0000000000..41c022410f
--- /dev/null
+++ b/apps/web/modules/api/v2/management/roles/lib/roles.ts
@@ -0,0 +1,26 @@
+import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
+import { ApiResponse } from "@/modules/api/v2/types/api-success";
+import { prisma } from "@formbricks/database";
+import { Result, err, ok } from "@formbricks/types/error-handlers";
+
+export const getRoles = async (): Promise, ApiErrorResponseV2>> => {
+ try {
+ // We use a raw query to get all the roles because we can't list enum options with prisma
+ const results = await prisma.$queryRaw<{ unnest: string }[]>`
+ SELECT unnest(enum_range(NULL::"OrganizationRole"));
+ `;
+
+ if (!results) {
+ // We set internal_server_error because it's an enum and we should always have the roles
+ return err({ type: "internal_server_error", details: [{ field: "roles", issue: "not found" }] });
+ }
+
+ const roles = results.map((row) => row.unnest);
+
+ return ok({
+ data: roles,
+ });
+ } catch (error) {
+ return err({ type: "internal_server_error", details: [{ field: "roles", issue: error.message }] });
+ }
+};
diff --git a/apps/web/modules/api/v2/management/roles/lib/tests/roles.test.ts b/apps/web/modules/api/v2/management/roles/lib/tests/roles.test.ts
new file mode 100644
index 0000000000..c23324382e
--- /dev/null
+++ b/apps/web/modules/api/v2/management/roles/lib/tests/roles.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { getRoles } from "../roles";
+
+// Mock prisma with a $queryRaw function
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ $queryRaw: vi.fn(),
+ },
+}));
+
+describe("getRoles", () => {
+ it("returns roles on success", async () => {
+ (prisma.$queryRaw as any).mockResolvedValueOnce([{ unnest: "ADMIN" }, { unnest: "MEMBER" }]);
+
+ const result = await getRoles();
+ expect(result.ok).toBe(true);
+
+ if (result.ok) {
+ expect(result.data.data).toEqual(["ADMIN", "MEMBER"]);
+ }
+ });
+
+ it("returns error if no results are found", async () => {
+ (prisma.$queryRaw as any).mockResolvedValueOnce(null);
+
+ const result = await getRoles();
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error?.type).toBe("internal_server_error");
+ }
+ });
+
+ it("returns error on exception", async () => {
+ vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error("Test DB error"));
+
+ const result = await getRoles();
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error.type).toBe("internal_server_error");
+ }
+ });
+});
diff --git a/apps/web/modules/api/v2/management/roles/route.ts b/apps/web/modules/api/v2/management/roles/route.ts
new file mode 100644
index 0000000000..829cbc2fe4
--- /dev/null
+++ b/apps/web/modules/api/v2/management/roles/route.ts
@@ -0,0 +1,19 @@
+import { responses } from "@/modules/api/v2/lib/response";
+import { handleApiError } from "@/modules/api/v2/lib/utils";
+import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
+import { getRoles } from "@/modules/api/v2/management/roles/lib/roles";
+import { NextRequest } from "next/server";
+
+export const GET = async (request: NextRequest) =>
+ authenticatedApiClient({
+ request,
+ handler: async () => {
+ const res = await getRoles();
+
+ if (res.ok) {
+ return responses.successResponse(res.data);
+ }
+
+ return handleApiError(request, res.error);
+ },
+ });
diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts
new file mode 100644
index 0000000000..6d0c6e2615
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts
@@ -0,0 +1,81 @@
+import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
+import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
+import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
+import { z } from "zod";
+import { ZodOpenApiOperationObject } from "zod-openapi";
+import { ZWebhook } from "@formbricks/database/zod/webhooks";
+
+export const getWebhookEndpoint: ZodOpenApiOperationObject = {
+ operationId: "getWebhook",
+ summary: "Get a webhook",
+ description: "Gets a webhook from the database.",
+ requestParams: {
+ path: z.object({
+ webhookId: webhookIdSchema,
+ }),
+ },
+ tags: ["Management API > Webhooks"],
+ responses: {
+ "200": {
+ description: "Webhook retrieved successfully.",
+ content: {
+ "application/json": {
+ schema: makePartialSchema(ZWebhook),
+ },
+ },
+ },
+ },
+};
+
+export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
+ operationId: "deleteWebhook",
+ summary: "Delete a webhook",
+ description: "Deletes a webhook from the database.",
+ tags: ["Management API > Webhooks"],
+ requestParams: {
+ path: z.object({
+ webhookId: webhookIdSchema,
+ }),
+ },
+ responses: {
+ "200": {
+ description: "Webhook deleted successfully.",
+ content: {
+ "application/json": {
+ schema: makePartialSchema(ZWebhook),
+ },
+ },
+ },
+ },
+};
+
+export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
+ operationId: "updateWebhook",
+ summary: "Update a webhook",
+ description: "Updates a webhook in the database.",
+ tags: ["Management API > Webhooks"],
+ requestParams: {
+ path: z.object({
+ webhookId: webhookIdSchema,
+ }),
+ },
+ requestBody: {
+ required: true,
+ description: "The webhook to update",
+ content: {
+ "application/json": {
+ schema: ZWebhookInput,
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "Webhook updated successfully.",
+ content: {
+ "application/json": {
+ schema: makePartialSchema(ZWebhook),
+ },
+ },
+ },
+ },
+};
diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts
new file mode 100644
index 0000000000..a6b335ba5e
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts
@@ -0,0 +1,20 @@
+import { WebhookSource } from "@prisma/client";
+import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
+import { PrismaErrorType } from "@formbricks/database/types/error";
+
+export const mockedPrismaWebhookUpdateReturn = {
+ id: "123",
+ url: "",
+ name: null,
+ createdAt: new Date("2025-03-24T07:27:36.850Z"),
+ updatedAt: new Date("2025-03-24T07:27:36.850Z"),
+ source: "user" as WebhookSource,
+ environmentId: "",
+ triggers: [],
+ surveyIds: [],
+};
+
+export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
+ code: PrismaErrorType.RecordDoesNotExist,
+ clientVersion: "PrismaClient 4.0.0",
+});
diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts
new file mode 100644
index 0000000000..858f7fc74c
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts
@@ -0,0 +1,126 @@
+import { webhookCache } from "@/lib/cache/webhook";
+import {
+ mockedPrismaWebhookUpdateReturn,
+ prismaNotFoundError,
+} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
+import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
+import { describe, expect, test, vi } from "vitest";
+import { z } from "zod";
+import { prisma } from "@formbricks/database";
+import { deleteWebhook, getWebhook, updateWebhook } from "../webhook";
+
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ webhook: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/webhook", () => ({
+ webhookCache: {
+ tag: {
+ byId: () => "mockTag",
+ },
+ revalidate: vi.fn(),
+ },
+}));
+
+describe("getWebhook", () => {
+ test("returns ok if webhook is found", async () => {
+ vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
+ const result = await getWebhook("123");
+ expect(result.ok).toBe(true);
+
+ if (result.ok) {
+ expect(result.data).toEqual({ id: "123" });
+ }
+ });
+
+ test("returns err if webhook not found", async () => {
+ vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
+ const result = await getWebhook("999");
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error?.type).toBe("not_found");
+ }
+ });
+
+ test("returns err on Prisma error", async () => {
+ vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(new Error("DB error"));
+ const result = await getWebhook("error");
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error.type).toBe("internal_server_error");
+ }
+ });
+});
+
+describe("updateWebhook", () => {
+ const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer;
+
+ test("returns ok on successful update", async () => {
+ vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
+ const result = await updateWebhook("123", mockedWebhookUpdateReturn);
+ expect(result.ok).toBe(true);
+
+ if (result.ok) {
+ expect(result.data).toEqual(mockedPrismaWebhookUpdateReturn);
+ }
+
+ expect(webhookCache.revalidate).toHaveBeenCalled();
+ });
+
+ test("returns not_found if record does not exist", async () => {
+ vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
+ const result = await updateWebhook("999", mockedWebhookUpdateReturn);
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error?.type).toBe("not_found");
+ }
+ });
+
+ test("returns internal_server_error if other error occurs", async () => {
+ vi.mocked(prisma.webhook.update).mockRejectedValueOnce(new Error("Unknown error"));
+ const result = await updateWebhook("abc", mockedWebhookUpdateReturn);
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error?.type).toBe("internal_server_error");
+ }
+ });
+});
+
+describe("deleteWebhook", () => {
+ test("returns ok on successful delete", async () => {
+ vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
+ const result = await deleteWebhook("123");
+ expect(result.ok).toBe(true);
+ expect(webhookCache.revalidate).toHaveBeenCalled();
+ });
+
+ test("returns not_found if record does not exist", async () => {
+ vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaNotFoundError);
+ const result = await deleteWebhook("999");
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error?.type).toBe("not_found");
+ }
+ });
+
+ test("returns internal_server_error on other errors", async () => {
+ vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Delete error"));
+ const result = await deleteWebhook("abc");
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error?.type).toBe("internal_server_error");
+ }
+ });
+});
diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts
new file mode 100644
index 0000000000..519cc3a9a7
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts
@@ -0,0 +1,111 @@
+import { webhookCache } from "@/lib/cache/webhook";
+import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
+import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
+import { Webhook } from "@prisma/client";
+import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
+import { z } from "zod";
+import { prisma } from "@formbricks/database";
+import { PrismaErrorType } from "@formbricks/database/types/error";
+import { cache } from "@formbricks/lib/cache";
+import { Result, err, ok } from "@formbricks/types/error-handlers";
+
+export const getWebhook = async (webhookId: string) =>
+ cache(
+ async (): Promise> => {
+ try {
+ const webhook = await prisma.webhook.findUnique({
+ where: {
+ id: webhookId,
+ },
+ });
+
+ if (!webhook) {
+ return err({
+ type: "not_found",
+ details: [{ field: "webhook", issue: "not found" }],
+ });
+ }
+
+ return ok(webhook);
+ } catch (error) {
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "webhook", issue: error.message }],
+ });
+ }
+ },
+ [`management-getWebhook-${webhookId}`],
+ {
+ tags: [webhookCache.tag.byId(webhookId)],
+ }
+ )();
+
+export const updateWebhook = async (
+ webhookId: string,
+ webhookInput: z.infer
+): Promise> => {
+ try {
+ const updatedWebhook = await prisma.webhook.update({
+ where: {
+ id: webhookId,
+ },
+ data: webhookInput,
+ });
+
+ webhookCache.revalidate({
+ id: webhookId,
+ });
+
+ return ok(updatedWebhook);
+ } catch (error) {
+ if (error instanceof PrismaClientKnownRequestError) {
+ if (
+ error.code === PrismaErrorType.RecordDoesNotExist ||
+ error.code === PrismaErrorType.RelatedRecordDoesNotExist
+ ) {
+ return err({
+ type: "not_found",
+ details: [{ field: "webhook", issue: "not found" }],
+ });
+ }
+ }
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "webhook", issue: error.message }],
+ });
+ }
+};
+
+export const deleteWebhook = async (webhookId: string): Promise> => {
+ try {
+ const deletedWebhook = await prisma.webhook.delete({
+ where: {
+ id: webhookId,
+ },
+ });
+
+ webhookCache.revalidate({
+ id: deletedWebhook.id,
+ environmentId: deletedWebhook.environmentId,
+ source: deletedWebhook.source,
+ });
+
+ return ok(deletedWebhook);
+ } catch (error) {
+ if (error instanceof PrismaClientKnownRequestError) {
+ if (
+ error.code === PrismaErrorType.RecordDoesNotExist ||
+ error.code === PrismaErrorType.RelatedRecordDoesNotExist
+ ) {
+ return err({
+ type: "not_found",
+ details: [{ field: "webhook", issue: "not found" }],
+ });
+ }
+ }
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "webhook", issue: error.message }],
+ });
+ }
+};
diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts
new file mode 100644
index 0000000000..2c1fa0cb53
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts
@@ -0,0 +1,156 @@
+import { responses } from "@/modules/api/v2/lib/response";
+import { handleApiError } from "@/modules/api/v2/lib/utils";
+import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
+import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
+import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
+import {
+ deleteWebhook,
+ getWebhook,
+ updateWebhook,
+} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook";
+import {
+ webhookIdSchema,
+ webhookUpdateSchema,
+} from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
+import { NextRequest } from "next/server";
+import { z } from "zod";
+
+export const GET = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
+ authenticatedApiClient({
+ request,
+ schemas: {
+ params: z.object({ webhookId: webhookIdSchema }),
+ },
+ externalParams: props.params,
+ handler: async ({ authentication, parsedInput }) => {
+ const { params } = parsedInput;
+
+ if (!params) {
+ return handleApiError(request, {
+ type: "bad_request",
+ details: [{ field: "params", issue: "missing" }],
+ });
+ }
+
+ const webhook = await getWebhook(params.webhookId);
+
+ if (!webhook.ok) {
+ return handleApiError(request, webhook.error);
+ }
+
+ const checkAuthorizationResult = await checkAuthorization({
+ authentication,
+ environmentId: webhook.ok ? webhook.data.environmentId : "",
+ });
+
+ if (!checkAuthorizationResult.ok) {
+ return handleApiError(request, checkAuthorizationResult.error);
+ }
+
+ return responses.successResponse(webhook);
+ },
+ });
+
+export const PUT = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
+ authenticatedApiClient({
+ request,
+ schemas: {
+ params: z.object({ webhookId: webhookIdSchema }),
+ body: webhookUpdateSchema,
+ },
+ externalParams: props.params,
+ handler: async ({ authentication, parsedInput }) => {
+ const { params, body } = parsedInput;
+
+ if (!body || !params) {
+ return handleApiError(request, {
+ type: "bad_request",
+ details: [{ field: !body ? "body" : "params", issue: "missing" }],
+ });
+ }
+
+ // get surveys environment
+ const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
+
+ if (!surveysEnvironmentId.ok) {
+ return handleApiError(request, surveysEnvironmentId.error);
+ }
+
+ // get webhook environment
+ const webhook = await getWebhook(params.webhookId);
+
+ if (!webhook.ok) {
+ return handleApiError(request, webhook.error);
+ }
+
+ // check webhook environment against the api key environment
+ const checkAuthorizationResult = await checkAuthorization({
+ authentication,
+ environmentId: webhook.ok ? webhook.data.environmentId : "",
+ });
+
+ if (!checkAuthorizationResult.ok) {
+ return handleApiError(request, checkAuthorizationResult.error);
+ }
+
+ // check if webhook environment matches the surveys environment
+ if (webhook.data.environmentId !== surveysEnvironmentId.data) {
+ return handleApiError(request, {
+ type: "bad_request",
+ details: [
+ { field: "surveys id", issue: "webhook environment does not match the surveys environment" },
+ ],
+ });
+ }
+
+ const updatedWebhook = await updateWebhook(params.webhookId, body);
+
+ if (!updatedWebhook.ok) {
+ return handleApiError(request, updatedWebhook.error);
+ }
+
+ return responses.successResponse(updatedWebhook);
+ },
+ });
+
+export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
+ authenticatedApiClient({
+ request,
+ schemas: {
+ params: z.object({ webhookId: webhookIdSchema }),
+ },
+ externalParams: props.params,
+ handler: async ({ authentication, parsedInput }) => {
+ const { params } = parsedInput;
+
+ if (!params) {
+ return handleApiError(request, {
+ type: "bad_request",
+ details: [{ field: "params", issue: "missing" }],
+ });
+ }
+
+ const webhook = await getWebhook(params.webhookId);
+
+ if (!webhook.ok) {
+ return handleApiError(request, webhook.error);
+ }
+
+ const checkAuthorizationResult = await checkAuthorization({
+ authentication,
+ environmentId: webhook.ok ? webhook.data.environmentId : "",
+ });
+
+ if (!checkAuthorizationResult.ok) {
+ return handleApiError(request, checkAuthorizationResult.error);
+ }
+
+ const deletedWebhook = await deleteWebhook(params.webhookId);
+
+ if (!deletedWebhook.ok) {
+ return handleApiError(request, deletedWebhook.error);
+ }
+
+ return responses.successResponse(deletedWebhook);
+ },
+ });
diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts
new file mode 100644
index 0000000000..9bcc7a708a
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+import { extendZodWithOpenApi } from "zod-openapi";
+import { ZWebhook } from "@formbricks/database/zod/webhooks";
+
+extendZodWithOpenApi(z);
+
+export const webhookIdSchema = z
+ .string()
+ .cuid2()
+ .openapi({
+ ref: "webhookId",
+ description: "The ID of the webhook",
+ param: {
+ name: "id",
+ in: "path",
+ },
+ });
+
+export const webhookUpdateSchema = ZWebhook.omit({
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ environmentId: true,
+}).openapi({
+ ref: "webhookUpdate",
+ description: "A webhook to update.",
+});
diff --git a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts
new file mode 100644
index 0000000000..92bac070d2
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts
@@ -0,0 +1,68 @@
+import {
+ deleteWebhookEndpoint,
+ getWebhookEndpoint,
+ updateWebhookEndpoint,
+} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/openapi";
+import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
+import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
+import { z } from "zod";
+import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
+import { ZWebhook } from "@formbricks/database/zod/webhooks";
+
+export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
+ operationId: "getWebhooks",
+ summary: "Get webhooks",
+ description: "Gets webhooks from the database.",
+ requestParams: {
+ query: ZGetWebhooksFilter.sourceType().required(),
+ },
+ tags: ["Management API > Webhooks"],
+ responses: {
+ "200": {
+ description: "Webhooks retrieved successfully.",
+ content: {
+ "application/json": {
+ schema: z.array(responseWithMetaSchema(makePartialSchema(ZWebhook))),
+ },
+ },
+ },
+ },
+};
+
+export const createWebhookEndpoint: ZodOpenApiOperationObject = {
+ operationId: "createWebhook",
+ summary: "Create a webhook",
+ description: "Creates a webhook in the database.",
+ tags: ["Management API > Webhooks"],
+ requestBody: {
+ required: true,
+ description: "The webhook to create",
+ content: {
+ "application/json": {
+ schema: ZWebhookInput,
+ },
+ },
+ },
+ responses: {
+ "201": {
+ description: "Webhook created successfully.",
+ content: {
+ "application/json": {
+ schema: makePartialSchema(ZWebhook),
+ },
+ },
+ },
+ },
+};
+
+export const webhookPaths: ZodOpenApiPathsObject = {
+ "/webhooks": {
+ get: getWebhooksEndpoint,
+ post: createWebhookEndpoint,
+ },
+ "/webhooks/{webhookId}": {
+ get: getWebhookEndpoint,
+ put: updateWebhookEndpoint,
+ delete: deleteWebhookEndpoint,
+ },
+};
diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts
new file mode 100644
index 0000000000..1314708eaf
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts
@@ -0,0 +1,36 @@
+import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
+import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
+import { describe, expect, it, vi } from "vitest";
+import { getWebhooksQuery } from "../utils";
+
+vi.mock("@/modules/api/v2/management/lib/utils", () => ({
+ pickCommonFilter: vi.fn(),
+ buildCommonFilterQuery: vi.fn(),
+}));
+
+describe("getWebhooksQuery", () => {
+ const environmentId = "env-123";
+
+ it("adds surveyIds condition when provided", () => {
+ const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter;
+ const result = getWebhooksQuery(environmentId, params);
+ expect(result).toBeDefined();
+ expect(result?.where).toMatchObject({
+ environmentId,
+ surveyIds: { hasSome: ["survey1"] },
+ });
+ });
+
+ it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => {
+ vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any);
+ getWebhooksQuery(environmentId, { surveyIds: ["survey1"] } as TGetWebhooksFilter);
+ expect(pickCommonFilter).toHaveBeenCalled();
+ expect(buildCommonFilterQuery).toHaveBeenCalled();
+ });
+
+ it("buildCommonFilterQuery is not called if no baseFilter is picked", () => {
+ vi.mocked(pickCommonFilter).mockReturnValue(undefined as any);
+ getWebhooksQuery(environmentId, {} as any);
+ expect(buildCommonFilterQuery).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts
new file mode 100644
index 0000000000..b0e2104d9c
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts
@@ -0,0 +1,117 @@
+import { webhookCache } from "@/lib/cache/webhook";
+import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
+import { WebhookSource } from "@prisma/client";
+import { describe, expect, it, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { captureTelemetry } from "@formbricks/lib/telemetry";
+import { createWebhook, getWebhooks } from "../webhook";
+
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ $transaction: vi.fn(),
+ webhook: {
+ findMany: vi.fn(),
+ count: vi.fn(),
+ create: vi.fn(),
+ },
+ },
+}));
+vi.mock("@/lib/cache/webhook", () => ({
+ webhookCache: {
+ revalidate: vi.fn(),
+ },
+}));
+vi.mock("@formbricks/lib/telemetry", () => ({
+ captureTelemetry: vi.fn(),
+}));
+
+describe("getWebhooks", () => {
+ const environmentId = "env1";
+ const params = {
+ limit: 10,
+ skip: 0,
+ };
+ const fakeWebhooks = [
+ { id: "w1", environmentId, name: "Webhook One" },
+ { id: "w2", environmentId, name: "Webhook Two" },
+ ];
+ const count = fakeWebhooks.length;
+
+ it("returns ok response with webhooks and meta", async () => {
+ vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeWebhooks, count]);
+
+ const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
+ expect(result.ok).toBe(true);
+
+ if (result.ok) {
+ expect(result.data.data).toEqual(fakeWebhooks);
+ expect(result.data.meta).toEqual({
+ total: count,
+ limit: params.limit,
+ offset: params.skip,
+ });
+ }
+ });
+
+ it("returns error when prisma.$transaction throws", async () => {
+ vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
+
+ const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error?.type).toEqual("internal_server_error");
+ }
+ });
+});
+
+describe("createWebhook", () => {
+ const inputWebhook = {
+ environmentId: "env1",
+ name: "New Webhook",
+ url: "http://example.com",
+ source: "user" as WebhookSource,
+ triggers: ["trigger1"],
+ surveyIds: ["s1", "s2"],
+ } as unknown as TWebhookInput;
+
+ const createdWebhook = {
+ id: "w100",
+ environmentId: inputWebhook.environmentId,
+ name: inputWebhook.name,
+ url: inputWebhook.url,
+ source: inputWebhook.source,
+ triggers: inputWebhook.triggers,
+ surveyIds: inputWebhook.surveyIds,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ it("creates a webhook and revalidates cache", async () => {
+ vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
+
+ const result = await createWebhook(inputWebhook);
+ expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
+ expect(prisma.webhook.create).toHaveBeenCalled();
+ expect(webhookCache.revalidate).toHaveBeenCalledWith({
+ environmentId: createdWebhook.environmentId,
+ source: createdWebhook.source,
+ });
+ expect(result.ok).toBe(true);
+
+ if (result.ok) {
+ expect(result.data).toEqual(createdWebhook);
+ }
+ });
+
+ it("returns error when creation fails", async () => {
+ vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));
+
+ const result = await createWebhook(inputWebhook);
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error.type).toEqual("internal_server_error");
+ }
+ });
+});
diff --git a/apps/web/modules/api/v2/management/webhooks/lib/utils.ts b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts
new file mode 100644
index 0000000000..59716e4cd8
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts
@@ -0,0 +1,35 @@
+import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
+import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
+import { Prisma } from "@prisma/client";
+
+export const getWebhooksQuery = (environmentId: string, params?: TGetWebhooksFilter) => {
+ let query: Prisma.WebhookFindManyArgs = {
+ where: {
+ environmentId,
+ },
+ };
+
+ if (!params) return query;
+
+ const { surveyIds } = params || {};
+
+ if (surveyIds) {
+ query = {
+ ...query,
+ where: {
+ ...query.where,
+ surveyIds: {
+ hasSome: surveyIds,
+ },
+ },
+ };
+ }
+
+ const baseFilter = pickCommonFilter(params);
+
+ if (baseFilter) {
+ query = buildCommonFilterQuery(query, baseFilter);
+ }
+
+ return query;
+};
diff --git a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts
new file mode 100644
index 0000000000..7d9d15fbf3
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts
@@ -0,0 +1,83 @@
+import { webhookCache } from "@/lib/cache/webhook";
+import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
+import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
+import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
+import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
+import { Prisma, Webhook } from "@prisma/client";
+import { prisma } from "@formbricks/database";
+import { captureTelemetry } from "@formbricks/lib/telemetry";
+import { Result, err, ok } from "@formbricks/types/error-handlers";
+
+export const getWebhooks = async (
+ environmentId: string,
+ params: TGetWebhooksFilter
+): Promise, ApiErrorResponseV2>> => {
+ try {
+ const [webhooks, count] = await prisma.$transaction([
+ prisma.webhook.findMany({
+ ...getWebhooksQuery(environmentId, params),
+ }),
+ prisma.webhook.count({
+ where: getWebhooksQuery(environmentId, params).where,
+ }),
+ ]);
+
+ if (!webhooks) {
+ return err({
+ type: "not_found",
+ details: [{ field: "webhooks", issue: "not_found" }],
+ });
+ }
+
+ return ok({
+ data: webhooks,
+ meta: {
+ total: count,
+ limit: params?.limit,
+ offset: params?.skip,
+ },
+ });
+ } catch (error) {
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "webhooks", issue: error.message }],
+ });
+ }
+};
+
+export const createWebhook = async (webhook: TWebhookInput): Promise> => {
+ captureTelemetry("webhook_created");
+
+ const { environmentId, name, url, source, triggers, surveyIds } = webhook;
+
+ try {
+ const prismaData: Prisma.WebhookCreateInput = {
+ environment: {
+ connect: {
+ id: environmentId,
+ },
+ },
+ name,
+ url,
+ source,
+ triggers,
+ surveyIds,
+ };
+
+ const createdWebhook = await prisma.webhook.create({
+ data: prismaData,
+ });
+
+ webhookCache.revalidate({
+ environmentId: createdWebhook.environmentId,
+ source: createdWebhook.source,
+ });
+
+ return ok(createdWebhook);
+ } catch (error) {
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "webhook", issue: error.message }],
+ });
+ }
+};
diff --git a/apps/web/modules/api/v2/management/webhooks/route.ts b/apps/web/modules/api/v2/management/webhooks/route.ts
new file mode 100644
index 0000000000..994635e13e
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/route.ts
@@ -0,0 +1,86 @@
+import { responses } from "@/modules/api/v2/lib/response";
+import { handleApiError } from "@/modules/api/v2/lib/utils";
+import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
+import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
+import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
+import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook";
+import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
+import { NextRequest } from "next/server";
+
+export const GET = async (request: NextRequest) =>
+ authenticatedApiClient({
+ request,
+ schemas: {
+ query: ZGetWebhooksFilter.sourceType(),
+ },
+ handler: async ({ authentication, parsedInput }) => {
+ const { query } = parsedInput;
+
+ if (!query) {
+ return handleApiError(request, {
+ type: "bad_request",
+ details: [{ field: "query", issue: "missing" }],
+ });
+ }
+
+ const environmentId = authentication.environmentId;
+
+ const res = await getWebhooks(environmentId, query);
+
+ if (res.ok) {
+ return responses.successResponse(res.data);
+ }
+
+ return handleApiError(request, res.error);
+ },
+ });
+
+export const POST = async (request: NextRequest) =>
+ authenticatedApiClient({
+ request,
+ schemas: {
+ body: ZWebhookInput,
+ },
+ handler: async ({ authentication, parsedInput }) => {
+ const { body } = parsedInput;
+
+ if (!body) {
+ return handleApiError(request, {
+ type: "bad_request",
+ details: [{ field: "body", issue: "missing" }],
+ });
+ }
+
+ const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
+
+ if (!environmentIdResult.ok) {
+ return handleApiError(request, environmentIdResult.error);
+ }
+
+ const environmentId = environmentIdResult.data;
+
+ if (body.environmentId !== environmentId) {
+ return handleApiError(request, {
+ type: "bad_request",
+ details: [{ field: "environmentId", issue: "does not match the surveys environment" }],
+ });
+ }
+
+ const checkAuthorizationResult = await checkAuthorization({
+ authentication,
+ environmentId,
+ });
+
+ if (!checkAuthorizationResult.ok) {
+ return handleApiError(request, checkAuthorizationResult.error);
+ }
+
+ const createWebhookResult = await createWebhook(body);
+
+ if (!createWebhookResult.ok) {
+ return handleApiError(request, createWebhookResult.error);
+ }
+
+ return responses.successResponse(createWebhookResult);
+ },
+ });
diff --git a/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts b/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts
new file mode 100644
index 0000000000..e049c92413
--- /dev/null
+++ b/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts
@@ -0,0 +1,30 @@
+import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
+import { z } from "zod";
+import { ZWebhook } from "@formbricks/database/zod/webhooks";
+
+export const ZGetWebhooksFilter = ZGetFilter.extend({
+ surveyIds: z.array(z.string().cuid2()).optional(),
+}).refine(
+ (data) => {
+ if (data.startDate && data.endDate && data.startDate > data.endDate) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "startDate must be before endDate",
+ }
+);
+
+export type TGetWebhooksFilter = z.infer;
+
+export const ZWebhookInput = ZWebhook.pick({
+ name: true,
+ url: true,
+ source: true,
+ environmentId: true,
+ triggers: true,
+ surveyIds: true,
+});
+
+export type TWebhookInput = z.infer;
diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts
index 2c5b179450..da89199cb7 100644
--- a/apps/web/modules/api/v2/openapi-document.ts
+++ b/apps/web/modules/api/v2/openapi-document.ts
@@ -2,7 +2,10 @@ import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-at
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
+import { rolePaths } from "@/modules/api/v2/management/roles/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
+import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
+import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
@@ -11,6 +14,7 @@ import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
import { ZResponse } from "@formbricks/database/zod/responses";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
+import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
@@ -23,10 +27,13 @@ const document = createDocument({
},
paths: {
...responsePaths,
+ ...bulkContactPaths,
...contactPaths,
...contactAttributePaths,
...contactAttributeKeyPaths,
...surveyPaths,
+ ...webhookPaths,
+ ...rolePaths,
},
servers: [
{
@@ -55,6 +62,14 @@ const document = createDocument({
name: "Management API > Surveys",
description: "Operations for managing surveys.",
},
+ {
+ name: "Management API > Webhooks",
+ description: "Operations for managing webhooks.",
+ },
+ {
+ name: "Management API > Roles",
+ description: "Operations for managing roles.",
+ },
],
components: {
securitySchemes: {
@@ -71,6 +86,8 @@ const document = createDocument({
contactAttribute: ZContactAttribute,
contactAttributeKey: ZContactAttributeKey,
survey: ZSurveyWithoutQuestionType,
+ webhook: ZWebhook,
+ role: z.array(z.string()),
},
},
security: [
@@ -80,4 +97,5 @@ const document = createDocument({
],
});
+// do not replace this with logger.info
console.log(yaml.stringify(document));
diff --git a/apps/web/modules/api/v2/types/api-error.ts b/apps/web/modules/api/v2/types/api-error.ts
index 06e69c3f49..10b8470232 100644
--- a/apps/web/modules/api/v2/types/api-error.ts
+++ b/apps/web/modules/api/v2/types/api-error.ts
@@ -1,4 +1,12 @@
-export type ApiErrorDetails = { field: string; issue: string }[];
+// We're naming the "params" field from zod (or otherwise) to "meta" since "params" is a bit confusing
+// We're still using the "params" type from zod though because it allows us to not reference `any` and directly use the zod types
+export type ApiErrorDetails = {
+ field: string;
+ issue: string;
+ meta?: {
+ [k: string]: unknown;
+ };
+}[];
export type ApiErrorResponseV2 =
| {
diff --git a/apps/web/modules/api/v2/types/api-filter.ts b/apps/web/modules/api/v2/types/api-filter.ts
new file mode 100644
index 0000000000..29fe9ab051
--- /dev/null
+++ b/apps/web/modules/api/v2/types/api-filter.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+export const ZGetFilter = z.object({
+ limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
+ skip: z.coerce.number().nonnegative().optional().default(0),
+ sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
+ order: z.enum(["asc", "desc"]).optional().default("desc"),
+ startDate: z.coerce.date().optional(),
+ endDate: z.coerce.date().optional(),
+});
+
+export type TGetFilter = z.infer;
diff --git a/apps/web/modules/api/v2/types/openapi-response.ts b/apps/web/modules/api/v2/types/openapi-response.ts
new file mode 100644
index 0000000000..50c2e8445a
--- /dev/null
+++ b/apps/web/modules/api/v2/types/openapi-response.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+export function responseWithMetaSchema(contentSchema: T) {
+ return z.object({
+ data: z.array(contentSchema).optional(),
+ meta: z
+ .object({
+ total: z.number().optional(),
+ limit: z.number().optional(),
+ offset: z.number().optional(),
+ })
+ .optional(),
+ });
+}
+
+// We use the partial method to make all properties optional so we don't show the response fields as required in the OpenAPI documentation
+export function makePartialSchema>(schema: T) {
+ return schema.partial();
+}
diff --git a/apps/web/modules/auth/invite/page.tsx b/apps/web/modules/auth/invite/page.tsx
index b569a2304d..b91402f5af 100644
--- a/apps/web/modules/auth/invite/page.tsx
+++ b/apps/web/modules/auth/invite/page.tsx
@@ -11,6 +11,7 @@ import { WEBAPP_URL } from "@formbricks/lib/constants";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { getUser, updateUser } from "@formbricks/lib/user/service";
+import { logger } from "@formbricks/logger";
import { ContentLayout } from "./components/content-layout";
interface InvitePageProps {
@@ -131,7 +132,7 @@ export const InvitePage = async (props: InvitePageProps) => {
);
} catch (e) {
- console.error(e);
+ logger.error(e, "Error in InvitePage");
return (
({
@@ -35,17 +36,17 @@ describe("createBrevoCustomer", () => {
});
it("should log an error if fetch fails", async () => {
- const consoleSpy = vi.spyOn(console, "error");
+ const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
await createBrevoCustomer({ id: "123", email: "test@example.com" });
- expect(consoleSpy).toHaveBeenCalledWith("Error sending user to Brevo:", expect.any(Error));
+ expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo");
});
it("should log the error response if fetch status is not 200", async () => {
- const consoleSpy = vi.spyOn(console, "error");
+ const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
@@ -53,6 +54,6 @@ describe("createBrevoCustomer", () => {
await createBrevoCustomer({ id: "123", email: "test@example.com" });
- expect(consoleSpy).toHaveBeenCalledWith("Error sending user to Brevo:", "Bad Request");
+ expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error sending user to Brevo");
});
});
diff --git a/apps/web/modules/auth/lib/brevo.ts b/apps/web/modules/auth/lib/brevo.ts
index 4308f4857b..6fd9e4a06c 100644
--- a/apps/web/modules/auth/lib/brevo.ts
+++ b/apps/web/modules/auth/lib/brevo.ts
@@ -1,5 +1,6 @@
import { BREVO_API_KEY, BREVO_LIST_ID } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
+import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
@@ -34,9 +35,10 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
});
if (res.status !== 200) {
- console.error("Error sending user to Brevo:", await res.text());
+ const errorText = await res.text();
+ logger.error({ errorText }, "Error sending user to Brevo");
}
} catch (error) {
- console.error("Error sending user to Brevo:", error);
+ logger.error(error, "Error sending user to Brevo");
}
};
diff --git a/apps/web/modules/auth/lib/user.test.ts b/apps/web/modules/auth/lib/user.test.ts
index 1cbbd63dc8..10a7f6b984 100644
--- a/apps/web/modules/auth/lib/user.test.ts
+++ b/apps/web/modules/auth/lib/user.test.ts
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
+import { PrismaErrorType } from "@formbricks/database/types/error";
import { userCache } from "@formbricks/lib/user/cache";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
@@ -57,7 +58,7 @@ describe("User Management", () => {
it("throws InvalidInputError when email already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
- code: "P2002",
+ code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.create).mockRejectedValueOnce(errToThrow);
@@ -86,7 +87,7 @@ describe("User Management", () => {
it("throws ResourceNotFoundError when user doesn't exist", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
- code: "P2016",
+ code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
diff --git a/apps/web/modules/auth/lib/user.ts b/apps/web/modules/auth/lib/user.ts
index ab47f40e6e..b5d647dc9e 100644
--- a/apps/web/modules/auth/lib/user.ts
+++ b/apps/web/modules/auth/lib/user.ts
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
+import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { userCache } from "@formbricks/lib/user/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
@@ -32,7 +33,10 @@ export const updateUser = async (id: string, data: TUserUpdateInput) => {
return updatedUser;
} catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === PrismaErrorType.RecordDoesNotExist
+ ) {
throw new ResourceNotFoundError("User", id);
}
throw error;
@@ -129,7 +133,10 @@ export const createUser = async (data: TUserCreateInput) => {
return user;
} catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === PrismaErrorType.UniqueConstraintViolation
+ ) {
throw new InvalidInputError("User with this email already exists");
}
diff --git a/apps/web/modules/auth/login/components/login-form.tsx b/apps/web/modules/auth/login/components/login-form.tsx
index cb9c80815e..d0eea0c62d 100644
--- a/apps/web/modules/auth/login/components/login-form.tsx
+++ b/apps/web/modules/auth/login/components/login-form.tsx
@@ -120,7 +120,6 @@ export const LoginForm = ({
};
const [showLogin, setShowLogin] = useState(false);
- const [isPasswordFocused, setIsPasswordFocused] = useState(false);
const [totpLogin, setTotpLogin] = useState(false);
const [totpBackup, setTotpBackup] = useState(false);
const formRef = useRef(null);
@@ -202,7 +201,8 @@ export const LoginForm = ({
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
- onFocus={() => setIsPasswordFocused(true)}
+ aria-label="password"
+ aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
@@ -214,7 +214,7 @@ export const LoginForm = ({
)}
/>
- {passwordResetEnabled && isPasswordFocused && (
+ {passwordResetEnabled && (