mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
52 Commits
v3.5.1
...
x-frame-op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f155b6bc | ||
|
|
da001834f5 | ||
|
|
f54352dd82 | ||
|
|
0fba0fae73 | ||
|
|
406ec88515 | ||
|
|
b97957d166 | ||
|
|
655ad6b9e0 | ||
|
|
f5ce42fc2d | ||
|
|
709cdf260d | ||
|
|
5c583028e0 | ||
|
|
c70008d1be | ||
|
|
13fa716fe8 | ||
|
|
c3af5b428f | ||
|
|
40e2f28e94 | ||
|
|
2964f2e079 | ||
|
|
e1a5291123 | ||
|
|
ef41f35209 | ||
|
|
2f64b202c1 | ||
|
|
2500c739ae | ||
|
|
63a9a6135b | ||
|
|
417005c6e9 | ||
|
|
cd1739c901 | ||
|
|
709917eb8f | ||
|
|
3ba70122d5 | ||
|
|
5ff025543e | ||
|
|
896d5bad12 | ||
|
|
e9dbaa3c28 | ||
|
|
d352d03071 | ||
|
|
ebefe775bb | ||
|
|
0852a961cc | ||
|
|
46f06f4c0e | ||
|
|
afb39e4aba | ||
|
|
2c6a90f82b | ||
|
|
e35f732e48 | ||
|
|
ec8b17dee2 | ||
|
|
947bc1a233 | ||
|
|
7050caa2f3 | ||
|
|
c4fd1a0a54 | ||
|
|
4de5f5c490 | ||
|
|
b3f336c959 | ||
|
|
010784c2b2 | ||
|
|
306f654617 | ||
|
|
60d0563487 | ||
|
|
777210ec42 | ||
|
|
8649522b5b | ||
|
|
71ebde06f4 | ||
|
|
d98eb5b46f | ||
|
|
6a2a8b74c8 | ||
|
|
43d5d3d719 | ||
|
|
5527f184b7 | ||
|
|
7dd5cf8b6e | ||
|
|
aec697f5b9 |
@@ -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 #
|
||||
#####################
|
||||
|
||||
3
.github/actions/cache-build-web/action.yml
vendored
3
.github/actions/cache-build-web/action.yml
vendored
@@ -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
84
.github/dependabot.yml
vendored
Normal 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"
|
||||
33
.github/workflows/cron-surveyStatusUpdate.yml
vendored
33
.github/workflows/cron-surveyStatusUpdate.yml
vendored
@@ -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
|
||||
33
.github/workflows/cron-weeklySummary.yml
vendored
33
.github/workflows/cron-weeklySummary.yml
vendored
@@ -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
|
||||
64
.github/workflows/deploy-formbricks-cloud.yml
vendored
Normal file
64
.github/workflows/deploy-formbricks-cloud.yml
vendored
Normal 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
|
||||
|
||||
33
.github/workflows/formbricks-release.yml
vendored
Normal file
33
.github/workflows/formbricks-release.yml
vendored
Normal 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 }}
|
||||
@@ -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
|
||||
|
||||
|
||||
17
.github/workflows/release-docker-github.yml
vendored
17
.github/workflows/release-docker-github.yml
vendored
@@ -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
|
||||
|
||||
|
||||
59
.github/workflows/release-docker.yml
vendored
59
.github/workflows/release-docker.yml
vendored
@@ -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
|
||||
15
.github/workflows/release-helm-chart.yml
vendored
15
.github/workflows/release-helm-chart.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/sonarqube.yml
vendored
4
.github/workflows/sonarqube.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 -->
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
77
apps/web/app/(app)/components/FormbricksClient.test.tsx
Normal file
77
apps/web/app/(app)/components/FormbricksClient.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
250
apps/web/app/(app)/environments/[environmentId]/layout.test.tsx
Normal file
250
apps/web/app/(app)/environments/[environmentId]/layout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
};
|
||||
390
apps/web/app/api/(internal)/insights/lib/utils.test.ts
Normal file
390
apps/web/app/api/(internal)/insights/lib/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ">
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user