Compare commits

...

52 Commits

Author SHA1 Message Date
Matti Nannt
d4f155b6bc chore: update storybook app dependencies (#5195) 2025-04-01 19:39:45 +02:00
Matti Nannt
da001834f5 chore: remove unused tailwind import from mobile SDK webviews (#5198) 2025-04-01 12:59:57 +00:00
Anshuman Pandey
f54352dd82 chore: changes storage cache to 5 minutes (#5196) 2025-04-01 07:25:17 +00:00
Matti Nannt
0fba0fae73 chore: remove posthog provider from top layout (#5169) 2025-04-01 06:24:17 +00:00
Anshuman Pandey
406ec88515 fix: adding back hidden fields for backwards compatibility (#5163) 2025-04-01 05:20:30 +00:00
Matti Nannt
b97957d166 chore(infra): increase ressource limits to 1 cpu & 1Gi mem (#5192) 2025-04-01 04:50:55 +00:00
Matti Nannt
655ad6b9e0 docs: fix response client api endpoint is missing environmentId (#5161) 2025-03-31 12:14:44 +02:00
Anshuman Pandey
f5ce42fc2d feat: api for uploading contacts in bulk (#5053)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-30 07:44:17 +00:00
dependabot[bot]
709cdf260d chore(deps): bump react-day-picker from 9.4.4 to 9.6.3 (#5149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-29 06:29:05 +00:00
Piyush Gupta
5c583028e0 feat: adds second domain (#4989)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-28 17:22:17 +00:00
Matti Nannt
c70008d1be chore: remove unused cron github actions (#5151) 2025-03-28 12:13:23 +01:00
Piyush Jain
13fa716fe8 chore(eks): add AmazonSSMManagedInstanceCore policy (#5152) 2025-03-28 08:57:56 +00:00
victorvhs017
c3af5b428f feat: added the roles endpoint, documentation, unity and e2e tests (#5068)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-28 04:53:39 +00:00
Matti Nannt
40e2f28e94 chore: add dependabot config (#5084) 2025-03-28 04:02:29 +01:00
victorvhs017
2964f2e079 chore: Refactored the Sentry next public env variable and added test files (#4979)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-27 13:04:25 +00:00
Jakob Schott
e1a5291123 fix: unify alert component (#5002)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-03-27 12:46:56 +00:00
Piyush Jain
ef41f35209 fix: rds (#5078) 2025-03-27 12:30:49 +01:00
Jakob Schott
2f64b202c1 fix: billing modal translation (#5079) 2025-03-27 10:50:24 +00:00
Piyush Gupta
2500c739ae fix: next-auth inactive session timeout changed 30days -> 1hr (#5066) 2025-03-27 09:54:35 +00:00
Matti Nannt
63a9a6135b fix: github issues url required login (#5077) 2025-03-27 04:42:57 +01:00
Dhruwang Jariwala
417005c6e9 fix: docker image not building (#5069)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-27 02:32:03 +00:00
Piyush Jain
cd1739c901 chore: updates enable PR comments for terraform plan (#5073) 2025-03-27 01:40:24 +00:00
Piyush Jain
709917eb8f chore: fix OneLeet compliance and update self-hosting docs (#5045)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-26 10:37:56 +01:00
Johannes
3ba70122d5 docs: update hidden field docs (#5067) 2025-03-26 01:45:31 -07:00
Dhruwang Jariwala
5ff025543e fix: static ttf in link survey preview (#5054) 2025-03-26 05:42:30 +00:00
Anshuman Pandey
896d5bad12 fix: adds network checks for the react-native sdk (#5034)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-26 04:51:42 +00:00
Jakob Schott
e9dbaa3c28 fix: survey id in summary (#5056) 2025-03-26 02:24:58 +00:00
dependabot[bot]
d352d03071 chore(deps-dev): bump the npm_and_yarn group across 2 directories with 1 update (#5062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-26 02:09:46 +00:00
victorvhs017
ebefe775bb fix: updated ttl property on cache-handler (#5065) 2025-03-26 01:53:59 +00:00
Anshuman Pandey
0852a961cc fix: adds unit tests for tsx files (#5001) 2025-03-25 16:58:47 +00:00
victorvhs017
46f06f4c0e feat: Added Webhooks in Management API V2 (#4949)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-25 14:28:44 +00:00
Matti Nannt
afb39e4aba docs: update license page (#5061) 2025-03-25 15:09:13 +01:00
Anshuman Pandey
2c6a90f82b fix: storage api endpoint openapi spec (#5057) 2025-03-25 12:16:35 +00:00
Dhruwang Jariwala
e35f732e48 fix: Remove hardcoded pg url (#4878) 2025-03-25 08:36:18 +00:00
Matti Nannt
ec8b17dee2 chore: use github bug report form instead of formbricks form (#5055) 2025-03-25 08:44:32 +01:00
Matti Nannt
947bc1a233 fix: Github release action unable to access secrets (#5052) 2025-03-25 04:23:49 +01:00
Johannes
7050caa2f3 fix: tweak password reset ux (#5049) 2025-03-24 08:32:11 -07:00
Johannes
c4fd1a0a54 docs: add EE feature to list (#5051) 2025-03-24 08:31:42 -07:00
victorvhs017
4de5f5c490 chore: Refactored the Posthog next public env variable and added test files (#4961)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-24 14:45:33 +00:00
Piyush Gupta
b3f336c959 fix: invite user bug (#5043)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-24 13:59:31 +00:00
victorvhs017
010784c2b2 chore: added new script with onload (#4987)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-24 12:12:37 +00:00
victorvhs017
306f654617 fix: add membership checks in remaining routes (#5026) 2025-03-24 04:49:04 -07:00
Anshuman Pandey
60d0563487 fix: adds missing storage api docs and fixes api key auth docs (#5031) 2025-03-24 06:43:57 +00:00
victorvhs017
777210ec42 fix: refactored the code to create a new link on click for single-use link surveys (#5038)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-24 05:39:17 +00:00
Piyush Jain
8649522b5b chore(actions): Update github actions to follow new release pattern (#5037) 2025-03-24 04:51:32 +01:00
Matti Nannt
71ebde06f4 docs: add prometheus to monitoring docs (#5042) 2025-03-24 04:15:31 +01:00
Peter Pesti-Varga
d98eb5b46f fix: Trust server in the iOS webview to allow to load the survey package (#5024) 2025-03-23 01:45:53 +01:00
dependabot[bot]
6a2a8b74c8 chore(deps): bump the npm_and_yarn group across 3 directories with 1 update (#5035)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-22 04:24:15 +01:00
Harsh Shrikant Bhat
43d5d3d719 chore: small email tweaks (#5019) 2025-03-21 06:14:56 -07:00
Piyush Gupta
5527f184b7 feat: adds configurable logging (#4914)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-21 06:09:13 -07:00
Piyush Jain
7dd5cf8b6e release: pre steps switch from vercel to aws (#5030) 2025-03-21 08:36:53 +01:00
Piyush Gupta
aec697f5b9 fix: role escalation in org settings (#4901)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-21 05:38:51 +00:00
444 changed files with 16042 additions and 4415 deletions

View File

@@ -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 #
#####################

View File

@@ -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

84
.github/dependabot.yml vendored Normal file
View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -2,16 +2,22 @@ name: 'Terraform'
on:
workflow_dispatch:
# TODO: enable it back when migration is completed.
push:
branches:
- main
paths:
- "infra/terraform/**"
pull_request:
branches:
- main
paths:
- "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
terraform:
@@ -59,12 +65,11 @@ jobs:
- 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')
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"
skip-comment: true
- name: Terraform Apply
id: apply

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"lucide-react": "0.468.0",
"next": "15.1.2",
"next": "15.2.3",
"react": "19.0.0",
"react-dom": "19.0.0"
},

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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(<OnboardingSetupInstructions {...defaultProps} />);
// 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(<OnboardingSetupInstructions {...defaultProps} />);
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(<OnboardingSetupInstructions {...defaultProps} />);
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(<OnboardingSetupInstructions {...defaultProps} />);
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"
);
});
});

View File

@@ -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)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -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)}();
</script>
<!-- END Formbricks Surveys -->
`;

View File

@@ -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
}
};

View File

@@ -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();

View File

@@ -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: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
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: <div data-testid="child-content">Hello!</div>,
});
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: <div data-testid="child-content">Hello!</div>,
})
).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: <div data-testid="child-content">Child</div>,
})
).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: <div data-testid="child-content">Hello!</div>,
})
).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: <div data-testid="child-content">Hello!</div>,
});
render(layoutElement);
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
});
});

View File

@@ -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}
/>
<ToasterClient />
{children}

View File

@@ -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`);
}

View File

@@ -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`);
}

View File

@@ -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) {

View File

@@ -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: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
<div data-testid="dev-environment-banner">{environment?.id || "no-env"}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
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: <div data-testid="child-content">Hello!</div>,
});
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: <div data-testid="child-content">Hello!</div>,
})
).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: <div>Child</div>,
})
).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: <div data-testid="child-content">Hello from children!</div>,
})
).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: <div>Child</div>,
})
).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: <div data-testid="child-content">Hello from children!</div>,
});
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");
});
});

View File

@@ -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 (
<>
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</ResponseFilterProvider>
</>
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</ResponseFilterProvider>
);
};

View File

@@ -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(<FormbricksClient userId="user-123" email="test@example.com" />);
// 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(<FormbricksClient userId="" email="test@example.com" />);
// 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();
});
});

View File

@@ -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<ReturnType<typeof usePostHog>>;
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<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={
{
name: "Test User",
email: "test@example.com",
role: "engineer",
objective: "increase_conversion",
} as TUser
}
environmentId="env-456"
organizationId="org-789"
organizationName="Test Org"
organizationBilling={
{
plan: "enterprise",
limits: { monthly: { responses: 1000, miu: 5000 }, projects: 10 },
} as TOrganizationBilling
}
isPosthogEnabled
/>
);
// 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<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled={false}
/>
);
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<typeof usePostHog>);
render(
<PosthogIdentify
// no user in session
session={{} as any}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled
/>
);
// 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<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled
/>
);
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();
});
});

View File

@@ -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;

View File

@@ -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<QuestionOption>;

View File

@@ -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 = ({
<TopControlButtons
environment={environment}
environments={environments}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>

View File

@@ -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 (
<div className="z-50 flex items-center space-x-2">
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
{isFormbricksCloud && (
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button
variant="ghost"
size="icon"
className="h-fit w-fit bg-slate-50 p-1"
onClick={() => {
formbricks.track("Top Menu: Product Feedback");
}}>
<MessageCircleQuestionIcon />
</Button>
</TooltipRenderer>
)}
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
<BugIcon />
</Link>
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.account")}>
<Button
variant="ghost"

View File

@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
export const fetchTables = async (environmentId: string, baseId: string) => {
@@ -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();

View File

@@ -1,3 +1,5 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
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();

View File

@@ -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<TSurvey>(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;

View File

@@ -1,3 +1,5 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
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();

View File

@@ -1,3 +1,5 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
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();

View File

@@ -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: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: () => <div data-testid="mock-storage-handler" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-environment-result">{children}</div>
),
}));
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: <div data-testid="child-content">Hello!</div>,
});
// 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: <div data-testid="child-content">Hello!</div>,
});
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: <div>Child</div>,
})
).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: <div data-testid="child-content">Hello from children!</div>,
})
).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: <div>Child</div>,
})
).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: <div>Child</div>,
})
).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: <div data-testid="child-content">Hello from children!</div>,
});
// 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();
});
});

View File

@@ -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 (
<>
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</ResponseFilterProvider>
</>
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</ResponseFilterProvider>
);
};

View File

@@ -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);

View File

@@ -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");
});
});

View File

@@ -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 }> }) => {
</SettingsCard>
)}
<SettingsId title={t("common.organization")} id={organization.id}></SettingsId>
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
</PageContentWrapper>
);
};

View File

@@ -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 (
<PageContentWrapper>
@@ -87,8 +59,8 @@ const Page = async (props) => {
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
webAppUrl={WEBAPP_URL}
user={user}
surveyDomain={surveyDomain}
/>
}>
{isAIEnabled && shouldGenerateInsights && (

View File

@@ -23,19 +23,19 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
surveyDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
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 = ({
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
@@ -159,8 +159,8 @@ export const ShareEmbedSurvey = ({
survey={survey}
email={email}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
webAppUrl={webAppUrl}
locale={user.locale}
/>
) : showView === "panel" ? (

View File

@@ -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 = ({
<ShareEmbedSurvey
key={key}
survey={survey}
surveyDomain={surveyDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
webAppUrl={webAppUrl}
user={user}
modalView={modalView}
/>

View File

@@ -20,8 +20,8 @@ interface EmbedViewProps {
survey: any;
email: string;
surveyUrl: string;
surveyDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
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" ? (
<LinkTab
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -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 }:
</p>
<ShareSurveyLink
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -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(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
/>
);
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(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
/>
);
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");
});
});
});

View File

@@ -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 =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

View File

@@ -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 (
<PageContentWrapper>
@@ -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}
/>
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
</PageContentWrapper>
);
};

View File

@@ -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: () => <div data-testid="no-mobile-overlay" />,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="ph-provider">{children}</div>
),
PostHogPageview: () => <div data-testid="ph-pageview" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
@@ -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");

View File

@@ -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 }) => {
<>
<NoMobileOverlay />
<Suspense>
<PostHogPageview />
<PostHogPageview
posthogEnabled={IS_POSTHOG_CONFIGURED}
postHogApiHost={POSTHOG_API_HOST}
postHogApiKey={POSTHOG_API_KEY}
/>
</Suspense>
<PHProvider>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
<IntercomClientWrapper user={user} />

View File

@@ -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<Metadata> => {
@@ -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();
}

View File

@@ -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,
}
);
};

View File

@@ -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,
}
);
};

View File

@@ -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);
});
});
});

View File

@@ -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;
}
};

View File

@@ -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),

View File

@@ -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!");
}
};

View File

@@ -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");
}
};

View File

@@ -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");
}
});
}

View File

@@ -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);
}
};

View File

@@ -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);
}

View File

@@ -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 };

View File

@@ -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<Response
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return responses.internalServerErrorResponse(error.message);
}
}

View File

@@ -15,6 +15,7 @@ import {
} from "@formbricks/lib/posthogServer";
import { projectCache } from "@formbricks/lib/project/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { getActionClassesForEnvironmentState } from "./actionClass";
@@ -89,7 +90,7 @@ export const getEnvironmentState = async (
},
});
} catch (err) {
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}

View File

@@ -4,6 +4,7 @@ import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { projectCache } from "@formbricks/lib/project/cache";
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 { TJsEnvironmentStateProject } from "@formbricks/types/js";
@@ -35,7 +36,7 @@ export const getProjectForEnvironmentState = reactCache(
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
logger.error(error, "Error getting project for environment state");
throw new DatabaseError(error.message);
}
throw error;

View File

@@ -5,6 +5,7 @@ import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
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 { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -80,7 +81,7 @@ export const getSurveysForEnvironmentState = reactCache(
return surveysPrisma.map((survey) => transformPrismaSurvey<TJsEnvironmentStateSurvey>(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;

View File

@@ -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<Response> => {
};
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);
}
};

View File

@@ -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 };

View File

@@ -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);
}
}

View File

@@ -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<TRe
});
} catch (err) {
// Log error but do not throw
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
}

View File

@@ -6,6 +6,7 @@ import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -107,7 +108,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
}
}

View File

@@ -9,6 +9,7 @@ import { validateLocalSignedUrl } from "@formbricks/lib/crypto";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { putFileToLocalStorage } from "@formbricks/lib/storage/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
interface Context {
params: Promise<{
@@ -30,18 +31,21 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
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<Response
return responses.unauthorizedResponse();
}
const base64String = formData.fileBase64String as string;
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
@@ -125,7 +129,7 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
message: "File uploaded successfully",
});
} catch (err) {
console.error("err: ", err);
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
}

View File

@@ -1,8 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { ZUploadFileRequest } from "@formbricks/types/storage";
import { uploadPrivateFile } from "./lib/uploadPrivateFile";
interface Context {
@@ -25,19 +27,22 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const params = await context.params;
const environmentId = params.environmentId;
const { fileName, fileType, surveyId } = await req.json();
const jsonInput = await req.json();
if (!surveyId) {
return responses.badRequestResponse("surveyId ID is required");
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
true
);
}
if (!fileName) {
return responses.badRequestResponse("fileName is required");
}
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
const { fileName, fileType, surveyId } = inputValidation.data;
const [survey, organization] = await Promise.all([
getSurvey(surveyId),

View File

@@ -1,3 +1,3 @@
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
import { OPTIONS, POST } from "@/modules/ee/contacts/api/v1/client/[environmentId]/user/route";
export { POST, OPTIONS };

View File

@@ -7,6 +7,7 @@ import { fetchAirtableAuthToken } from "@formbricks/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createOrUpdateIntegration } from "@formbricks/lib/integration/service";
import { logger } from "@formbricks/logger";
const getEmail = async (token: string) => {
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");

View File

@@ -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");
}

View File

@@ -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<Response> => {
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");
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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<TResponse> => {
@@ -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");
}

View File

@@ -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<TRe
});
} catch (err) {
// Log error but do not throw
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
}

View File

@@ -4,6 +4,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest } from "next/server";
import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { createResponse } from "./lib/response";
@@ -45,7 +46,7 @@ export const POST = async (request: Request): Promise<Response> => {
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<Response> => {
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);
}
}

View File

@@ -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<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const accessType = "public"; // public files are accessible by anyone
const headersList = await headers();

View File

@@ -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<Response> => {
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");
}

View File

@@ -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);
}

View File

@@ -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<TSurvey | null> => {
@@ -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");
}

View File

@@ -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);

View File

@@ -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<Response> => {
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");
}

View File

@@ -29,7 +29,6 @@ export const GET = async (req: NextRequest) => {
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
{name}
</h2>
<span tw="text-slate-600 text-xl">Complete in ~ 4 minutes</span>
</div>
</div>
<div tw="flex justify-end mr-10 ">

View File

@@ -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<Webhook> => {
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}`);

View File

@@ -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);
}
};

View File

@@ -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 };

View File

@@ -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<Response
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
logger.error({ error, url: request.url }, "Error creating display");
return responses.internalServerErrorResponse(error.message);
}
}

View File

@@ -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 };

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