diff --git a/.env.example b/.env.example index b4f51e2def..8d66de1e91 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ NEXTAUTH_SECRET= # You can use: `openssl rand -hex 32` to generate a secure one CRON_SECRET= +# Set the minimum log level(debug, info, warn, error, fatal) +LOG_LEVEL=info + ############## # DATABASE # ############## @@ -77,6 +80,9 @@ S3_ENDPOINT_URL= # Force path style for S3 compatible storage (0 for disabled, 1 for enabled) S3_FORCE_PATH_STYLE=0 +# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL) +# SURVEY_URL=https://survey.example.com + ##################### # Disable Features # ##################### diff --git a/.github/actions/cache-build-web/action.yml b/.github/actions/cache-build-web/action.yml index 6ae00d0203..25d18f4245 100644 --- a/.github/actions/cache-build-web/action.yml +++ b/.github/actions/cache-build-web/action.yml @@ -57,9 +57,6 @@ runs: run: | RANDOM_KEY=$(openssl rand -hex 32) sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env - sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env - sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env - sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env shell: bash diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..d27ee547a7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,84 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem + directory: "/" # Root package.json + schedule: + interval: "weekly" + versioning-strategy: increase + + # Apps directory packages + - package-ecosystem: "npm" + directory: "/apps/demo" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/apps/demo-react-native" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/apps/storybook" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/apps/web" + schedule: + interval: "weekly" + + # Packages directory + - package-ecosystem: "npm" + directory: "/packages/database" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/lib" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/types" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/config-eslint" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/config-prettier" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/config-typescript" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/js-core" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/surveys" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/packages/logger" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/cron-surveyStatusUpdate.yml b/.github/workflows/cron-surveyStatusUpdate.yml deleted file mode 100644 index 7563067db5..0000000000 --- a/.github/workflows/cron-surveyStatusUpdate.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Cron - Survey status update - -on: - workflow_dispatch: - # "Scheduled workflows run on the latest commit on the default or base branch." - # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule - schedule: - # Runs "At 00:00." (see https://crontab.guru) - - cron: "0 0 * * *" - -permissions: - contents: read - -jobs: - cron-weeklySummary: - env: - APP_URL: ${{ secrets.APP_URL }} - CRON_SECRET: ${{ secrets.CRON_SECRET }} - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - name: cURL request - if: ${{ env.APP_URL && env.CRON_SECRET }} - run: | - curl ${{ env.APP_URL }}/api/cron/survey-status \ - -X POST \ - -H 'content-type: application/json' \ - -H 'x-api-key: ${{ env.CRON_SECRET }}' \ - --fail diff --git a/.github/workflows/cron-weeklySummary.yml b/.github/workflows/cron-weeklySummary.yml deleted file mode 100644 index dc570a7cf9..0000000000 --- a/.github/workflows/cron-weeklySummary.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Cron - Weekly summary - -on: - workflow_dispatch: - # "Scheduled workflows run on the latest commit on the default or base branch." - # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule - schedule: - # Runs “At 08:00 on Monday.” (see https://crontab.guru) - - cron: "0 8 * * 1" -permissions: - contents: read - -jobs: - cron-weeklySummary: - permissions: - contents: read - env: - APP_URL: ${{ secrets.APP_URL }} - CRON_SECRET: ${{ secrets.CRON_SECRET }} - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 - with: - egress-policy: audit - - name: cURL request - if: ${{ env.APP_URL && env.CRON_SECRET }} - run: | - curl ${{ env.APP_URL }}/api/cron/weekly-summary \ - -X POST \ - -H 'content-type: application/json' \ - -H 'x-api-key: ${{ env.CRON_SECRET }}' \ - --fail diff --git a/.github/workflows/deploy-formbricks-cloud.yml b/.github/workflows/deploy-formbricks-cloud.yml new file mode 100644 index 0000000000..bff5c196e3 --- /dev/null +++ b/.github/workflows/deploy-formbricks-cloud.yml @@ -0,0 +1,64 @@ +name: Formbricks Cloud Deployment + +on: + workflow_dispatch: + inputs: + VERSION: + description: 'The version of the Docker image to release' + required: true + type: string + REPOSITORY: + description: 'The repository to use for the Docker image' + required: false + type: string + default: 'ghcr.io/formbricks/formbricks' + workflow_call: + inputs: + VERSION: + description: 'The version of the Docker image to release' + required: true + type: string + REPOSITORY: + description: 'The repository to use for the Docker image' + required: false + type: string + default: 'ghcr.io/formbricks/formbricks' + +permissions: + id-token: write + contents: write + +jobs: + helmfile-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} + aws-region: "eu-central-1" + + - name: Setup Cluster Access + run: | + aws eks update-kubeconfig --name formbricks-prod-eks --region eu-central-1 + env: + AWS_REGION: eu-central-1 + + - uses: helmfile/helmfile-action@v2 + env: + VERSION: ${{ inputs.VERSION }} + REPOSITORY: ${{ inputs.REPOSITORY }} + FORMBRICKS_S3_BUCKET: ${{ secrets.FORMBRICKS_S3_BUCKET }} + FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }} + FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }} + with: + helm-plugins: > + https://github.com/databus23/helm-diff, + https://github.com/jkroepke/helm-secrets + helmfile-args: apply + helmfile-auto-init: "false" + helmfile-workdirectory: infra/formbricks-cloud-helm + diff --git a/.github/workflows/formbricks-release.yml b/.github/workflows/formbricks-release.yml new file mode 100644 index 0000000000..dca6cfd53f --- /dev/null +++ b/.github/workflows/formbricks-release.yml @@ -0,0 +1,33 @@ +name: Build, release & deploy Formbricks images + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + docker-build: + name: Build & release stable docker image + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/workflows/release-docker-github.yml + secrets: inherit + + helm-chart-release: + name: Release Helm Chart + uses: ./.github/workflows/release-helm-chart.yml + secrets: inherit + needs: + - docker-build + with: + VERSION: ${{ needs.docker-build.outputs.VERSION }} + + deploy-formbricks-cloud: + name: Deploy Helm Chart to Formbricks Cloud + secrets: inherit + uses: ./.github/workflows/deploy-formbricks-cloud.yml + needs: + - docker-build + - helm-chart-release + with: + VERSION: ${{ needs.docker-build.outputs.VERSION }} diff --git a/.github/workflows/release-docker-github-experimental.yml b/.github/workflows/release-docker-github-experimental.yml index c009debdcd..25b8e5e61e 100644 --- a/.github/workflows/release-docker-github-experimental.yml +++ b/.github/workflows/release-docker-github-experimental.yml @@ -15,7 +15,6 @@ env: IMAGE_NAME: ${{ github.repository }}-experimental TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public" permissions: contents: read @@ -80,6 +79,9 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + secrets: | + database_url=${{ secrets.DUMMY_DATABASE_URL }} + encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release-docker-github.yml b/.github/workflows/release-docker-github.yml index 837dcfe4b5..457940fb7e 100644 --- a/.github/workflows/release-docker-github.yml +++ b/.github/workflows/release-docker-github.yml @@ -6,10 +6,11 @@ name: Docker Release to Github # documentation. on: - workflow_dispatch: - push: - tags: - - "v*" + workflow_call: + outputs: + VERSION: + description: release version + value: ${{ jobs.build.outputs.VERSION }} env: # Use docker.io for Docker Hub if empty @@ -18,7 +19,6 @@ env: IMAGE_NAME: ${{ github.repository }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public" permissions: contents: read @@ -33,6 +33,9 @@ jobs: # with sigstore/fulcio when running outside of PRs. id-token: write + outputs: + VERSION: ${{ steps.extract_release_tag.outputs.VERSION }} + steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 @@ -48,6 +51,7 @@ jobs: TAG=${{ github.ref }} TAG=${TAG#refs/tags/v} echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV + echo "VERSION=$TAG" >> $GITHUB_OUTPUT - name: Update package.json version run: | @@ -95,6 +99,9 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + secrets: | + database_url=${{ secrets.DUMMY_DATABASE_URL }} + encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml deleted file mode 100644 index d3367333ca..0000000000 --- a/.github/workflows/release-docker.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Release on Dockerhub - -on: - push: - tags: - - "v*" - -permissions: - contents: read - -jobs: - release-image-on-dockerhub: - name: Release on Dockerhub - permissions: - contents: read - runs-on: ubuntu-latest - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public" - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - name: Checkout Repo - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - - - name: Get Release Tag - id: extract_release_tag - run: | - TAG=${{ github.ref }} - TAG=${TAG#refs/tags/v} - echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV - - - name: Update package.json version - run: | - sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json - cat ./apps/web/package.json | grep version - - - name: Log in to Docker Hub - uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0 - - - name: Build and push Docker image - uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1 - with: - context: . - file: ./apps/web/Dockerfile - push: true - tags: | - ${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }} - ${{ secrets.DOCKER_USERNAME }}/formbricks:latest diff --git a/.github/workflows/release-helm-chart.yml b/.github/workflows/release-helm-chart.yml index 85cfe16894..fbe39e160d 100644 --- a/.github/workflows/release-helm-chart.yml +++ b/.github/workflows/release-helm-chart.yml @@ -1,9 +1,12 @@ name: Publish Helm Chart on: - release: - types: - - published + workflow_call: + inputs: + VERSION: + description: 'The version of the Helm chart to release' + required: true + type: string permissions: contents: read @@ -39,8 +42,8 @@ jobs: - name: Update Chart.yaml with new version run: | - yq -i ".version = \"${VERSION#v}\"" helm-chart/Chart.yaml - yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml + yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml + yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml - name: Package Helm chart run: | @@ -48,4 +51,4 @@ jobs: - name: Push Helm chart to GitHub Container Registry run: | - helm push formbricks-${VERSION#v}.tgz oci://ghcr.io/formbricks/helm-charts + helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 65d26fe7b7..b7dfe776df 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -46,11 +46,7 @@ jobs: - name: Run tests with coverage run: | - cd apps/web pnpm test:coverage - cd ../../ - # The Vitest coverage config is in your vite.config.mts - - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 env: diff --git a/.github/workflows/terrafrom-plan-and-apply.yml b/.github/workflows/terrafrom-plan-and-apply.yml index 49f2b99081..78d0c72e6c 100644 --- a/.github/workflows/terrafrom-plan-and-apply.yml +++ b/.github/workflows/terrafrom-plan-and-apply.yml @@ -3,16 +3,21 @@ name: 'Terraform' on: workflow_dispatch: # TODO: enable it back when migration is completed. -# push: -# branches: -# - main -# pull_request: -# branches: -# - main + push: + branches: + - main + paths: + - "infra/terraform/**" + pull_request: + branches: + - main + paths: + - "infra/terraform/**" permissions: id-token: write contents: write + pull-requests: write jobs: terraform: @@ -58,18 +63,17 @@ jobs: run: terraform plan -out .planfile working-directory: infra/terraform -# - name: Post PR comment -# uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0 -# if: always() && github.ref != 'refs/heads/main' && (steps.validate.outcome == 'success' || steps.validate.outcome == 'failure') -# with: -# token: ${{ github.token }} -# planfile: .planfile -# working-directory: "infra/terraform" -# skip-comment: true + - name: Post PR comment + uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0 + if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') + with: + token: ${{ github.token }} + planfile: .planfile + working-directory: "infra/terraform" - name: Terraform Apply id: apply -# if: github.ref == 'refs/heads/main' && github.event_name == 'push' + if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: terraform apply .planfile working-directory: "infra/terraform" diff --git a/apps/demo/package.json b/apps/demo/package.json index ce27d6244b..1ee31994d2 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@formbricks/demo", - "version": "0.1.0", + "version": "0.0.0", "private": true, "scripts": { "clean": "rimraf .turbo node_modules .next", @@ -12,8 +12,8 @@ }, "dependencies": { "@formbricks/js": "workspace:*", - "lucide-react": "0.468.0", - "next": "15.1.2", + "lucide-react": "0.486.0", + "next": "15.2.4", "react": "19.0.0", "react-dom": "19.0.0" }, diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 5ffdc138d3..68db164108 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -11,30 +11,30 @@ "clean": "rimraf .turbo node_modules dist storybook-static" }, "dependencies": { - "eslint-plugin-react-refresh": "0.4.16", - "react": "19.0.0", - "react-dom": "19.0.0" + "eslint-plugin-react-refresh": "0.4.19", + "react": "19.1.0", + "react-dom": "19.1.0" }, "devDependencies": { - "@chromatic-com/storybook": "3.2.2", + "@chromatic-com/storybook": "3.2.6", "@formbricks/config-typescript": "workspace:*", - "@storybook/addon-a11y": "8.4.7", - "@storybook/addon-essentials": "8.4.7", - "@storybook/addon-interactions": "8.4.7", - "@storybook/addon-links": "8.4.7", - "@storybook/addon-onboarding": "8.4.7", - "@storybook/blocks": "8.4.7", - "@storybook/react": "8.4.7", - "@storybook/react-vite": "8.4.7", - "@storybook/test": "8.4.7", - "@typescript-eslint/eslint-plugin": "8.18.0", - "@typescript-eslint/parser": "8.18.0", + "@storybook/addon-a11y": "8.6.11", + "@storybook/addon-essentials": "8.6.11", + "@storybook/addon-interactions": "8.6.11", + "@storybook/addon-links": "8.6.11", + "@storybook/addon-onboarding": "8.6.11", + "@storybook/blocks": "8.6.11", + "@storybook/react": "8.6.11", + "@storybook/react-vite": "8.6.11", + "@storybook/test": "8.6.11", + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", "@vitejs/plugin-react": "4.3.4", - "esbuild": "0.25.1", - "eslint-plugin-storybook": "0.11.1", + "esbuild": "0.25.2", + "eslint-plugin-storybook": "0.12.0", "prop-types": "15.8.1", - "storybook": "8.4.7", - "tsup": "8.3.5", - "vite": "6.0.9" + "storybook": "8.6.11", + "tsup": "8.4.0", + "vite": "6.2.4" } } diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index b16f185c9f..3e8039e5f2 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -24,17 +24,27 @@ RUN corepack enable # Install necessary build tools and compilers RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq -# Set hardcoded environment variables -ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public" -ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime" -ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime" -ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime" +# BuildKit secret handling without hardcoded fallback values +# This approach relies entirely on secrets passed from GitHub Actions +RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ + echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \ + echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \ + echo 'else' >> /tmp/read-secrets.sh && \ + echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \ + echo 'fi' >> /tmp/read-secrets.sh && \ + echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \ + echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \ + echo 'else' >> /tmp/read-secrets.sh && \ + echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \ + echo 'fi' >> /tmp/read-secrets.sh && \ + echo 'exec "$@"' >> /tmp/read-secrets.sh && \ + chmod +x /tmp/read-secrets.sh -ARG NEXT_PUBLIC_SENTRY_DSN ARG SENTRY_AUTH_TOKEN -# Increase Node.js memory limit -# ENV NODE_OPTIONS="--max_old_space_size=4096" +# Increase Node.js memory limit as a regular build argument +ARG NODE_OPTIONS="--max_old_space_size=4096" +ENV NODE_OPTIONS=${NODE_OPTIONS} # Set the working directory WORKDIR /app @@ -53,8 +63,11 @@ RUN touch apps/web/.env # Install the dependencies RUN pnpm install -# Build the project -RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web... +# Build the project using our secret reader script +# This mounts the secrets only during this build step without storing them in layers +RUN --mount=type=secret,id=database_url \ + --mount=type=secret,id=encryption_key \ + /tmp/read-secrets.sh pnpm build --filter=@formbricks/web... # Extract Prisma version RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt @@ -85,6 +98,8 @@ COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src +COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules +COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist # Copy Prisma-specific generated files COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client @@ -93,14 +108,16 @@ COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_mod COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt . COPY /docker/cronjobs /app/docker/cronjobs -# Copy only @paralleldrive/cuid2 and @noble/hashes +# Copy required dependencies COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2 COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes +COPY --from=installer /app/node_modules/zod ./node_modules/zod -RUN npm install -g tsx typescript prisma +RUN npm install -g tsx typescript prisma pino-pretty EXPOSE 3000 ENV HOSTNAME "0.0.0.0" +ENV NODE_ENV="production" # USER nextjs # Prepare volume for uploads @@ -119,4 +136,4 @@ CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \ fi; \ (cd packages/database && npm run db:migrate:deploy) && \ (cd packages/database && npm run db:create-saml-database:deploy) && \ - exec node apps/web/server.js + exec node apps/web/server.js \ No newline at end of file diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx new file mode 100644 index 0000000000..d26c801406 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx @@ -0,0 +1,103 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions"; + +// Mock react-hot-toast so we can assert that a success message is shown +vi.mock("react-hot-toast", () => ({ + __esModule: true, + default: { + success: vi.fn(), + }, +})); + +// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy. +beforeAll(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + writable: true, + value: { + // Using a mockResolvedValue resolves the promise as writeText is async. + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); +}); + +describe("OnboardingSetupInstructions", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + // Provide some default props for testing + const defaultProps = { + environmentId: "env-123", + webAppUrl: "https://example.com", + channel: "app" as const, // Assuming channel is either "app" or "website" + widgetSetupCompleted: false, + }; + + test("renders HTML tab content by default", () => { + render(); + + // Since the default active tab is "html", we check for a unique text + expect( + screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i) + ).toBeInTheDocument(); + + // The HTML snippet contains a marker comment + expect(screen.getByText("START")).toBeInTheDocument(); + + // Verify the "Copy Code" button is present + expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument(); + }); + + test("renders NPM tab content when selected", async () => { + render(); + const user = userEvent.setup(); + + // Click on the "NPM" tab to switch views. + const npmTab = screen.getByText("NPM"); + await user.click(npmTab); + + // Check that the install commands are present + expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument(); + expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument(); + + // Verify the "Read Docs" link has the correct URL (based on channel prop) + const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i }); + expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides"); + }); + + test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => { + render(); + const user = userEvent.setup(); + + const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText"); + + // Click the "Copy Code" button + const copyButton = screen.getByRole("button", { name: /common.copy_code/i }); + await user.click(copyButton); + + // Ensure navigator.clipboard.writeText was called. + expect(writeTextSpy).toHaveBeenCalled(); + const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string; + + // Check that the pasted snippet contains the expected environment values + expect(writtenText).toContain('var appUrl = "https://example.com"'); + expect(writtenText).toContain('var environmentId = "env-123"'); + + // Verify that a success toast was shown + expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard"); + }); + + test("renders step-by-step manual link with correct URL in HTML tab", () => { + render(); + const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i }); + expect(manualLink).toHaveAttribute( + "href", + "https://formbricks.com/docs/app-surveys/framework-guides#html" + ); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx index 33822ca1c9..7ceb44322a 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx @@ -36,7 +36,7 @@ export const OnboardingSetupInstructions = ({ !function(){ var appUrl = "${webAppUrl}"; var environmentId = "${environmentId}"; - var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl})},500)}(); + var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); `; @@ -46,7 +46,7 @@ export const OnboardingSetupInstructions = ({ !function(){ var appUrl = "${webAppUrl}"; var environmentId = "${environmentId}"; - var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl })},500)}(); + var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); `; diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts index c6f5e0c82b..fa11c26f11 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts @@ -1,13 +1,10 @@ import { getDefaultEndingCard } from "@/app/lib/templates"; import { createId } from "@paralleldrive/cuid2"; import { TFnType } from "@tolgee/react"; +import { logger } from "@formbricks/logger"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TXMTemplate } from "@formbricks/types/templates"; -function logError(error: Error, context: string) { - console.error(`Error in ${context}:`, error); -} - export const getXMSurveyDefault = (t: TFnType): TXMTemplate => { try { return { @@ -19,7 +16,7 @@ export const getXMSurveyDefault = (t: TFnType): TXMTemplate => { }, }; } catch (error) { - logError(error, "getXMSurveyDefault"); + logger.error(error, "Failed to create default XM survey template"); throw error; // Re-throw after logging } }; @@ -449,7 +446,7 @@ export const getXMTemplates = (t: TFnType): TXMTemplate[] => { enpsSurvey(t), ]; } catch (error) { - logError(error, "getXMTemplates"); + logger.error(error, "Unable to load XM templates, returning empty array"); return []; // Return an empty array or handle as needed } }; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx index 5dea2c22d6..5bc2b635e7 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx @@ -1,27 +1,25 @@ import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; -import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Header } from "@/modules/ui/components/header"; import { getTranslate } from "@/tolgee/server"; -import { getServerSession } from "next-auth"; import { notFound, redirect } from "next/navigation"; -import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service"; +import { getOrganizationsByUserId } from "@formbricks/lib/organization/service"; import { getUser } from "@formbricks/lib/user/service"; const Page = async (props) => { const params = await props.params; const t = await getTranslate(); - const session = await getServerSession(authOptions); - if (!session || !session.user) { + + const { session, organization } = await getOrganizationAuth(params.organizationId); + + if (!session?.user) { return redirect(`/auth/login`); } const user = await getUser(session.user.id); if (!user) return notFound(); - const organization = await getOrganization(params.organizationId); - if (!organization) return notFound(); - const organizations = await getOrganizationsByUserId(session.user.id); const { features } = await getEnterpriseLicense(); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx new file mode 100644 index 0000000000..850229ddca --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx @@ -0,0 +1,156 @@ +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; +import { getOrganization } from "@formbricks/lib/organization/service"; +import { getUser } from "@formbricks/lib/user/service"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import ProjectOnboardingLayout from "./layout"; + +// Mock all the modules and functions that this layout uses: + +vi.mock("@formbricks/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); +vi.mock("@formbricks/lib/organization/auth", () => ({ + canUserAccessOrganization: vi.fn(), +})); +vi.mock("@formbricks/lib/organization/service", () => ({ + getOrganization: vi.fn(), +})); +vi.mock("@formbricks/lib/user/service", () => ({ + getUser: vi.fn(), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(() => { + // Return a mock translator that just returns the key + return (key: string) => key; + }), +})); + +// mock the child components +vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({ + PosthogIdentify: () =>
, +})); +vi.mock("@/modules/ui/components/toaster-client", () => ({ + ToasterClient: () =>
, +})); + +describe("ProjectOnboardingLayout", () => { + beforeEach(() => { + cleanup(); + }); + + it("redirects to /auth/login if there is no session", async () => { + // Mock no session + vi.mocked(getServerSession).mockResolvedValueOnce(null); + + const layoutElement = await ProjectOnboardingLayout({ + params: { organizationId: "org-123" }, + children:
Hello!
, + }); + + expect(redirect).toHaveBeenCalledWith("/auth/login"); + // Layout returns nothing after redirect + expect(layoutElement).toBeUndefined(); + }); + + it("throws an error if user does not exist", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ + user: { id: "user-123" }, + }); + vi.mocked(getUser).mockResolvedValueOnce(null); // no user in DB + + await expect( + ProjectOnboardingLayout({ + params: { organizationId: "org-123" }, + children:
Hello!
, + }) + ).rejects.toThrow("common.user_not_found"); + }); + + it("throws AuthorizationError if user cannot access organization", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); + vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false); + + await expect( + ProjectOnboardingLayout({ + params: { organizationId: "org-123" }, + children:
Child
, + }) + ).rejects.toThrow("common.not_authorized"); + }); + + it("throws an error if organization does not exist", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); + vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true); + vi.mocked(getOrganization).mockResolvedValueOnce(null); + + await expect( + ProjectOnboardingLayout({ + params: { organizationId: "org-123" }, + children:
Hello!
, + }) + ).rejects.toThrow("common.organization_not_found"); + }); + + it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => { + // Provide valid data + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser); + vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true); + vi.mocked(getOrganization).mockResolvedValueOnce({ + id: "org-123", + name: "Test Org", + billing: { + plan: "enterprise", + }, + } as TOrganization); + + let layoutElement: React.ReactNode; + // Because it's an async server component, do it in an act + await act(async () => { + layoutElement = await ProjectOnboardingLayout({ + params: { organizationId: "org-123" }, + children:
Hello!
, + }); + render(layoutElement); + }); + + expect(screen.getByTestId("child-content")).toHaveTextContent("Hello!"); + expect(screen.getByTestId("posthog-identify")).toBeInTheDocument(); + expect(screen.getByTestId("toaster-client")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx index bd5130ad43..6c16a24140 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx @@ -4,6 +4,7 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants"; import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; import { getOrganization } from "@formbricks/lib/organization/service"; import { getUser } from "@formbricks/lib/user/service"; @@ -16,7 +17,8 @@ const ProjectOnboardingLayout = async (props) => { const t = await getTranslate(); const session = await getServerSession(authOptions); - if (!session || !session.user) { + + if (!session?.user) { return redirect(`/auth/login`); } @@ -26,8 +28,9 @@ const ProjectOnboardingLayout = async (props) => { } const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); + if (!isAuthorized) { - throw AuthorizationError; + throw new AuthorizationError(t("common.not_authorized")); } const organization = await getOrganization(params.organizationId); @@ -43,6 +46,7 @@ const ProjectOnboardingLayout = async (props) => { organizationId={organization.id} organizationName={organization.name} organizationBilling={organization.billing} + isPosthogEnabled={IS_POSTHOG_CONFIGURED} /> {children} diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx index 4ac6aa42bb..4309addd10 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx @@ -1,10 +1,9 @@ import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; -import { authOptions } from "@/modules/auth/lib/authOptions"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; import { getTranslate } from "@/tolgee/server"; import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react"; -import { getServerSession } from "next-auth"; import Link from "next/link"; import { redirect } from "next/navigation"; import { getUserProjects } from "@formbricks/lib/project/service"; @@ -17,8 +16,10 @@ interface ChannelPageProps { const Page = async (props: ChannelPageProps) => { const params = await props.params; - const session = await getServerSession(authOptions); - if (!session || !session.user) { + + const { session } = await getOrganizationAuth(params.organizationId); + + if (!session?.user) { return redirect(`/auth/login`); } diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx index c3574c0a9c..a570a6ed89 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx @@ -1,10 +1,9 @@ import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; -import { authOptions } from "@/modules/auth/lib/authOptions"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; import { getTranslate } from "@/tolgee/server"; import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react"; -import { getServerSession } from "next-auth"; import Link from "next/link"; import { redirect } from "next/navigation"; import { getUserProjects } from "@formbricks/lib/project/service"; @@ -17,8 +16,10 @@ interface ModePageProps { const Page = async (props: ModePageProps) => { const params = await props.params; - const session = await getServerSession(authOptions); - if (!session || !session.user) { + + const { session } = await getOrganizationAuth(params.organizationId); + + if (!session?.user) { return redirect(`/auth/login`); } diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx index 9c7d4f856c..5a6098b3d3 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx @@ -1,16 +1,14 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; -import { authOptions } from "@/modules/auth/lib/authOptions"; import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; import { getTranslate } from "@/tolgee/server"; import { XIcon } from "lucide-react"; -import { getServerSession } from "next-auth"; import Link from "next/link"; import { redirect } from "next/navigation"; import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants"; -import { getOrganization } from "@formbricks/lib/organization/service"; import { getUserProjects } from "@formbricks/lib/project/service"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; @@ -29,25 +27,20 @@ const Page = async (props: ProjectSettingsPageProps) => { const searchParams = await props.searchParams; const params = await props.params; const t = await getTranslate(); - const session = await getServerSession(authOptions); - if (!session || !session.user) { + const { session, organization } = await getOrganizationAuth(params.organizationId); + + if (!session?.user) { return redirect(`/auth/login`); } - const channel = searchParams.channel || null; - const industry = searchParams.industry || null; - const mode = searchParams.mode || "surveys"; + const channel = searchParams.channel ?? null; + const industry = searchParams.industry ?? null; + const mode = searchParams.mode ?? "surveys"; const projects = await getUserProjects(session.user.id, params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId); - const organization = await getOrganization(params.organizationId); - - if (!organization) { - throw new Error(t("common.organization_not_found")); - } - const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); if (!organizationTeams) { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx new file mode 100644 index 0000000000..f3de86d04f --- /dev/null +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx @@ -0,0 +1,191 @@ +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { getUser } from "@formbricks/lib/user/service"; +import { TEnvironment } from "@formbricks/types/environment"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import SurveyEditorEnvironmentLayout from "./layout"; + +// mock all dependencies + +vi.mock("@formbricks/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); +vi.mock("@formbricks/lib/environment/auth", () => ({ + hasUserEnvironmentAccess: vi.fn(), +})); +vi.mock("@formbricks/lib/environment/service", () => ({ + getEnvironment: vi.fn(), +})); +vi.mock("@formbricks/lib/organization/service", () => ({ + getOrganizationByEnvironmentId: vi.fn(), +})); +vi.mock("@formbricks/lib/user/service", () => ({ + getUser: vi.fn(), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(() => { + return (key: string) => key; // trivial translator returning the key + }), +})); + +// mock child components rendered by the layout: +vi.mock("@/app/(app)/components/FormbricksClient", () => ({ + FormbricksClient: () =>
, +})); +vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({ + PosthogIdentify: () =>
, +})); +vi.mock("@/modules/ui/components/toaster-client", () => ({ + ToasterClient: () =>
, +})); +vi.mock("@/modules/ui/components/dev-environment-banner", () => ({ + DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => ( +
{environment?.id || "no-env"}
+ ), +})); +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe("SurveyEditorEnvironmentLayout", () => { + beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("redirects to /auth/login if there is no session", async () => { + // Mock no session + vi.mocked(getServerSession).mockResolvedValueOnce(null); + + const layoutElement = await SurveyEditorEnvironmentLayout({ + params: { environmentId: "env-123" }, + children:
Hello!
, + }); + + expect(redirect).toHaveBeenCalledWith("/auth/login"); + // No JSX is returned after redirect + expect(layoutElement).toBeUndefined(); + }); + + it("throws error if user does not exist in DB", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce(null); // user not found + + await expect( + SurveyEditorEnvironmentLayout({ + params: { environmentId: "env-123" }, + children:
Hello!
, + }) + ).rejects.toThrow("common.user_not_found"); + }); + + it("throws AuthorizationError if user does not have environment access", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false); + + await expect( + SurveyEditorEnvironmentLayout({ + params: { environmentId: "env-123" }, + children:
Child
, + }) + ).rejects.toThrow(AuthorizationError); + }); + + it("throws if no organization is found", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null); + + await expect( + SurveyEditorEnvironmentLayout({ + params: { environmentId: "env-123" }, + children:
Hello from children!
, + }) + ).rejects.toThrow("common.organization_not_found"); + }); + + it("throws if no environment is found", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization); + vi.mocked(getEnvironment).mockResolvedValueOnce(null); + + await expect( + SurveyEditorEnvironmentLayout({ + params: { environmentId: "env-123" }, + children:
Child
, + }) + ).rejects.toThrow("common.environment_not_found"); + }); + + it("renders environment layout if everything is valid", async () => { + // Provide all valid data + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); + vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization); + vi.mocked(getEnvironment).mockResolvedValueOnce({ + id: "env-123", + name: "My Test Environment", + } as unknown as TEnvironment); + + // Because it's an async server component, we typically wrap in act(...) + let layoutElement: React.ReactNode; + + await act(async () => { + layoutElement = await SurveyEditorEnvironmentLayout({ + params: { environmentId: "env-123" }, + children:
Hello from children!
, + }); + render(layoutElement); + }); + + // Now confirm we got the child plus all the mocked sub-components + expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!"); + expect(screen.getByTestId("posthog-identify")).toBeInTheDocument(); + expect(screen.getByTestId("formbricks-client")).toBeInTheDocument(); + expect(screen.getByTestId("mock-toaster")).toBeInTheDocument(); + expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument(); + expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123"); + }); +}); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx index 7b1470c669..f8c34c8dd3 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx @@ -7,6 +7,7 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; @@ -20,7 +21,8 @@ const SurveyEditorEnvironmentLayout = async (props) => { const t = await getTranslate(); const session = await getServerSession(authOptions); - if (!session || !session.user) { + + if (!session?.user) { return redirect(`/auth/login`); } @@ -46,24 +48,23 @@ const SurveyEditorEnvironmentLayout = async (props) => { } return ( - <> - - - - -
- -
{children}
-
-
- + + + + +
+ +
{children}
+
+
); }; diff --git a/apps/web/app/(app)/components/FormbricksClient.test.tsx b/apps/web/app/(app)/components/FormbricksClient.test.tsx new file mode 100644 index 0000000000..a0e0b986ca --- /dev/null +++ b/apps/web/app/(app)/components/FormbricksClient.test.tsx @@ -0,0 +1,77 @@ +import { render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import formbricks from "@formbricks/js"; +import { FormbricksClient } from "./FormbricksClient"; + +// Mock next/navigation hooks. +vi.mock("next/navigation", () => ({ + usePathname: () => "/test-path", + useSearchParams: () => new URLSearchParams("foo=bar"), +})); + +// Mock the environment variables. +vi.mock("@formbricks/lib/env", () => ({ + env: { + NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test", + NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com", + }, +})); + +// Mock the flag that enables Formbricks. +vi.mock("@/app/lib/formbricks", () => ({ + formbricksEnabled: true, +})); + +// Mock the Formbricks SDK module. +vi.mock("@formbricks/js", () => ({ + __esModule: true, + default: { + setup: vi.fn(), + setUserId: vi.fn(), + setEmail: vi.fn(), + registerRouteChange: vi.fn(), + }, +})); + +describe("FormbricksClient", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => { + const mockSetup = vi.spyOn(formbricks, "setup"); + const mockSetUserId = vi.spyOn(formbricks, "setUserId"); + const mockSetEmail = vi.spyOn(formbricks, "setEmail"); + const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); + + render(); + + // Expect the first effect to call setup and assign the provided user details. + expect(mockSetup).toHaveBeenCalledWith({ + environmentId: "env-test", + appUrl: "https://api.test.com", + }); + expect(mockSetUserId).toHaveBeenCalledWith("user-123"); + expect(mockSetEmail).toHaveBeenCalledWith("test@example.com"); + + // And the second effect should always register the route change when Formbricks is enabled. + expect(mockRegisterRouteChange).toHaveBeenCalled(); + }); + + test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => { + const mockSetup = vi.spyOn(formbricks, "setup"); + const mockSetUserId = vi.spyOn(formbricks, "setUserId"); + const mockSetEmail = vi.spyOn(formbricks, "setEmail"); + const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); + + render(); + + // Since userId is falsy, the first effect should not call setup or assign user details. + expect(mockSetup).not.toHaveBeenCalled(); + expect(mockSetUserId).not.toHaveBeenCalled(); + expect(mockSetEmail).not.toHaveBeenCalled(); + + // The second effect only checks formbricksEnabled, so registerRouteChange should be called. + expect(mockRegisterRouteChange).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx new file mode 100644 index 0000000000..90eb344a89 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx @@ -0,0 +1,153 @@ +// PosthogIdentify.test.tsx +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { Session } from "next-auth"; +import { usePostHog } from "posthog-js/react"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TOrganizationBilling } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import { PosthogIdentify } from "./PosthogIdentify"; + +type PartialPostHog = Partial>; + +vi.mock("posthog-js/react", () => ({ + usePostHog: vi.fn(), +})); + +describe("PosthogIdentify", () => { + beforeEach(() => { + cleanup(); + }); + + it("identifies the user and sets groups when isPosthogEnabled is true", () => { + const mockIdentify = vi.fn(); + const mockGroup = vi.fn(); + + const mockPostHog: PartialPostHog = { + identify: mockIdentify, + group: mockGroup, + }; + + vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType); + + render( + + ); + + // verify that identify is called with the session user id + extra info + expect(mockIdentify).toHaveBeenCalledWith("user-123", { + name: "Test User", + email: "test@example.com", + role: "engineer", + objective: "increase_conversion", + }); + + // environment + organization groups + expect(mockGroup).toHaveBeenCalledTimes(2); + expect(mockGroup).toHaveBeenCalledWith("environment", "env-456", { name: "env-456" }); + expect(mockGroup).toHaveBeenCalledWith("organization", "org-789", { + name: "Test Org", + plan: "enterprise", + responseLimit: 1000, + miuLimit: 5000, + }); + }); + + it("does nothing if isPosthogEnabled is false", () => { + const mockIdentify = vi.fn(); + const mockGroup = vi.fn(); + + const mockPostHog: PartialPostHog = { + identify: mockIdentify, + group: mockGroup, + }; + + vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType); + + render( + + ); + + expect(mockIdentify).not.toHaveBeenCalled(); + expect(mockGroup).not.toHaveBeenCalled(); + }); + + it("does nothing if session user is missing", () => { + const mockIdentify = vi.fn(); + const mockGroup = vi.fn(); + + const mockPostHog: PartialPostHog = { + identify: mockIdentify, + group: mockGroup, + }; + + vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType); + + render( + + ); + + // Because there's no session.user, we skip identify + expect(mockIdentify).not.toHaveBeenCalled(); + expect(mockGroup).not.toHaveBeenCalled(); + }); + + it("identifies user but does not group if environmentId/organizationId not provided", () => { + const mockIdentify = vi.fn(); + const mockGroup = vi.fn(); + + const mockPostHog: PartialPostHog = { + identify: mockIdentify, + group: mockGroup, + }; + + vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType); + + render( + + ); + + expect(mockIdentify).toHaveBeenCalledWith("user-123", { + name: "Test User", + email: "test@example.com", + role: undefined, + objective: undefined, + }); + // No environmentId or organizationId => no group calls + expect(mockGroup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx index 91ed3d2f21..9bb42a3338 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.tsx @@ -3,12 +3,9 @@ import type { Session } from "next-auth"; import { usePostHog } from "posthog-js/react"; import { useEffect } from "react"; -import { env } from "@formbricks/lib/env"; import { TOrganizationBilling } from "@formbricks/types/organizations"; import { TUser } from "@formbricks/types/user"; -const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST; - interface PosthogIdentifyProps { session: Session; user: TUser; @@ -16,6 +13,7 @@ interface PosthogIdentifyProps { organizationId?: string; organizationName?: string; organizationBilling?: TOrganizationBilling; + isPosthogEnabled: boolean; } export const PosthogIdentify = ({ @@ -25,11 +23,12 @@ export const PosthogIdentify = ({ organizationId, organizationName, organizationBilling, + isPosthogEnabled, }: PosthogIdentifyProps) => { const posthog = usePostHog(); useEffect(() => { - if (posthogEnabled && session.user && posthog) { + if (isPosthogEnabled && session.user && posthog) { posthog.identify(session.user.id, { name: user.name, email: user.email, @@ -59,6 +58,7 @@ export const PosthogIdentify = ({ user.email, user.role, user.objective, + isPosthogEnabled, ]); return null; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx index d485b222a2..ec62057383 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx @@ -6,7 +6,7 @@ import { } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; import { getTodayDate } from "@/app/lib/surveys/surveys"; -import { createContext, useCallback, useContext, useState } from "react"; +import React, { createContext, useCallback, useContext, useState } from "react"; export interface FilterValue { questionType: Partial; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx index eea9218900..205de99e2d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx @@ -1,6 +1,5 @@ import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { TEnvironment } from "@formbricks/types/environment"; import { TOrganizationRole } from "@formbricks/types/memberships"; @@ -24,7 +23,6 @@ export const TopControlBar = ({ diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx index 2646546db3..22ef9d8218 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx @@ -6,9 +6,9 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { Button } from "@/modules/ui/components/button"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { useTranslate } from "@tolgee/react"; -import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react"; +import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import formbricks from "@formbricks/js"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TEnvironment } from "@formbricks/types/environment"; import { TOrganizationRole } from "@formbricks/types/memberships"; @@ -16,7 +16,6 @@ import { TOrganizationRole } from "@formbricks/types/memberships"; interface TopControlButtonsProps { environment: TEnvironment; environments: TEnvironment[]; - isFormbricksCloud: boolean; membershipRole?: TOrganizationRole; projectPermission: TTeamPermission | null; } @@ -24,7 +23,6 @@ interface TopControlButtonsProps { export const TopControlButtons = ({ environment, environments, - isFormbricksCloud, membershipRole, projectPermission, }: TopControlButtonsProps) => { @@ -38,19 +36,15 @@ export const TopControlButtons = ({ return (
{!isBilling && } - {isFormbricksCloud && ( - - - - )} + + + + + + ), +})); + +// Mock InsightSheet to display its open/closed state and the insight title. +vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({ + InsightSheet: ({ isOpen, insight }) => ( +
+ {isOpen ? "InsightSheet Open" : "InsightSheet Closed"} + {insight && ` - ${insight.title}`} +
+ ), +})); + +// Create an array of 15 dummy insights. +// Even-indexed insights will have the category "complaint" +// and odd-indexed insights will have "praise". +const dummyInsights = Array.from({ length: 15 }, (_, i) => ({ + id: `insight-${i}`, + _count: { documentInsights: i }, + title: `Insight Title ${i}`, + description: `Insight Description ${i}`, + category: i % 2 === 0 ? "complaint" : "praise", + updatedAt: new Date(), + createdAt: new Date(), + environmentId: "environment-1", +})) as TSurveyQuestionSummaryOpenText["insights"]; + +// Helper function to render the component with default props. +const renderComponent = (props = {}) => { + const defaultProps = { + insights: dummyInsights, + questionId: "question-1", + surveyId: "survey-1", + documentsFilter: {}, + isFetching: false, + documentsPerPage: 5, + locale: "en" as TUserLocale, + }; + + return render(); +}; + +// --- Tests --- +describe("InsightView Component", () => { + test("renders table headers", () => { + renderComponent(); + expect(screen.getByText("#")).toBeInTheDocument(); + expect(screen.getByText("common.title")).toBeInTheDocument(); + expect(screen.getByText("common.description")).toBeInTheDocument(); + expect(screen.getByText("environments.experience.category")).toBeInTheDocument(); + }); + + test('shows "no insights found" when insights array is empty', () => { + renderComponent({ insights: [] }); + expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument(); + }); + + test("does not render insights when isFetching is true", () => { + renderComponent({ isFetching: true, insights: [] }); + expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument(); + }); + + test("filters insights based on selected tab", async () => { + renderComponent(); + + // Click on the "complaint" tab. + const complaintTab = screen.getAllByText("environments.experience.complaint")[0]; + fireEvent.click(complaintTab); + + // Grab all table rows from the table body. + const rows = await screen.findAllByRole("row"); + + // Check that none of the rows include text from a "praise" insight. + rows.forEach((row) => { + expect(row.textContent).not.toEqual(/Insight Title 1/); + }); + }); + + test("load more button increases visible insights count", () => { + renderComponent(); + // Initially, "Insight Title 10" should not be visible because only 10 items are shown. + expect(screen.queryByText("Insight Title 10")).not.toBeInTheDocument(); + + // Get all buttons with the text "common.load_more" and filter for those that are visible. + const loadMoreButtons = screen.getAllByRole("button", { name: /common\.load_more/i }); + expect(loadMoreButtons.length).toBeGreaterThan(0); + + // Click the first visible "load more" button. + fireEvent.click(loadMoreButtons[0]); + + // Now, "Insight Title 10" should be visible. + expect(screen.getByText("Insight Title 10")).toBeInTheDocument(); + }); + + test("opens insight sheet when a row is clicked", () => { + renderComponent(); + // Get all elements that display "Insight Title 0" and use the first one to find its table row + const cells = screen.getAllByText("Insight Title 0"); + expect(cells.length).toBeGreaterThan(0); + const rowElement = cells[0].closest("tr"); + expect(rowElement).not.toBeNull(); + // Simulate a click on the table row + fireEvent.click(rowElement!); + + // Get all instances of the InsightSheet component + const sheets = screen.getAllByTestId("insight-sheet"); + // Filter for the one that contains the expected text + const matchingSheet = sheets.find((sheet) => + sheet.textContent?.includes("InsightSheet Open - Insight Title 0") + ); + + expect(matchingSheet).toBeDefined(); + expect(matchingSheet).toHaveTextContent("InsightSheet Open - Insight Title 0"); + }); + + test("category badge calls onCategoryChange and updates the badge (even if value remains the same)", () => { + renderComponent(); + // Get the first category badge. For index 0, the category is "complaint". + const categoryBadge = screen.getAllByTestId("category-badge")[0]; + + // It should display "complaint" initially. + expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint"); + + // Click the category badge to trigger onCategoryChange. + fireEvent.click(categoryBadge); + + // After clicking, the badge should still display "complaint" (since our mock simply passes the current value). + expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint"); + }); +}); diff --git a/apps/web/modules/ee/insights/experience/actions.ts b/apps/web/modules/ee/insights/experience/actions.ts index 0e705b170a..4f50bc0a8d 100644 --- a/apps/web/modules/ee/insights/experience/actions.ts +++ b/apps/web/modules/ee/insights/experience/actions.ts @@ -12,6 +12,7 @@ import { checkAIPermission } from "@/modules/ee/insights/actions"; import { ZInsightFilterCriteria } from "@/modules/ee/insights/experience/types/insights"; import { z } from "zod"; import { ZInsight } from "@formbricks/database/zod/insights"; +import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; import { getInsights, updateInsight } from "./lib/insights"; import { getStats } from "./lib/stats"; @@ -113,10 +114,14 @@ export const updateInsightAction = authenticatedActionClient return await updateInsight(parsedInput.insightId, parsedInput.data); } catch (error) { - console.error("Error updating insight:", { - insightId: parsedInput.insightId, - error, - }); + logger.error( + { + insightId: parsedInput.insightId, + error, + }, + "Error updating insight" + ); + if (error instanceof Error) { throw new Error(`Failed to update insight: ${error.message}`); } diff --git a/apps/web/modules/ee/insights/experience/components/insight-view.test.tsx b/apps/web/modules/ee/insights/experience/components/insight-view.test.tsx new file mode 100644 index 0000000000..0232660c80 --- /dev/null +++ b/apps/web/modules/ee/insights/experience/components/insight-view.test.tsx @@ -0,0 +1,215 @@ +import { TInsightWithDocumentCount } from "@/modules/ee/insights/experience/types/insights"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TUserLocale } from "@formbricks/types/user"; +import { InsightView } from "./insight-view"; + +// Mock the translation hook to simply return the key. +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Mock the action that fetches insights. +const mockGetEnvironmentInsightsAction = vi.fn(); +vi.mock("../actions", () => ({ + getEnvironmentInsightsAction: (...args: any[]) => mockGetEnvironmentInsightsAction(...args), +})); + +// Mock InsightSheet so we can assert on its open state. +vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({ + InsightSheet: ({ + isOpen, + insight, + }: { + isOpen: boolean; + insight: any; + setIsOpen: any; + handleFeedback: any; + documentsFilter: any; + documentsPerPage: number; + locale: string; + }) => ( +
+ {isOpen ? `InsightSheet Open${insight ? ` - ${insight.title}` : ""}` : "InsightSheet Closed"} +
+ ), +})); + +// Mock InsightLoading. +vi.mock("./insight-loading", () => ({ + InsightLoading: () =>
Loading...
, +})); + +// For simplicity, we won’t mock CategoryBadge so it renders normally. +// If needed, you can also mock it similar to InsightSheet. + +// --- Dummy Data --- +const dummyInsight1 = { + id: "1", + title: "Insight 1", + description: "Description 1", + category: "featureRequest", + _count: { documentInsights: 5 }, +}; +const dummyInsight2 = { + id: "2", + title: "Insight 2", + description: "Description 2", + category: "featureRequest", + _count: { documentInsights: 3 }, +}; +const dummyInsightComplaint = { + id: "3", + title: "Complaint Insight", + description: "Complaint Description", + category: "complaint", + _count: { documentInsights: 10 }, +}; +const dummyInsightPraise = { + id: "4", + title: "Praise Insight", + description: "Praise Description", + category: "praise", + _count: { documentInsights: 8 }, +}; + +// A helper to render the component with required props. +const renderComponent = (props = {}) => { + const defaultProps = { + statsFrom: new Date("2023-01-01"), + environmentId: "env-1", + insightsPerPage: 2, + documentsPerPage: 5, + locale: "en-US" as TUserLocale, + }; + + return render(); +}; + +// --- Tests --- +describe("InsightView Component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders "no insights found" message when insights array is empty', async () => { + // Set up the mock to return an empty array. + mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [] }); + renderComponent(); + // Wait for the useEffect to complete. + await waitFor(() => { + expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument(); + }); + }); + + test("renders table rows when insights are fetched", async () => { + // Return two insights for the initial fetch. + mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] }); + renderComponent(); + // Wait until the insights are rendered. + await waitFor(() => { + expect(screen.getByText("Insight 1")).toBeInTheDocument(); + expect(screen.getByText("Insight 2")).toBeInTheDocument(); + }); + }); + + test("opens insight sheet when a table row is clicked", async () => { + mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] }); + renderComponent(); + // Wait for the insight to appear. + await waitFor(() => { + expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0); + }); + + // Instead of grabbing the first "Insight 1" cell, + // get all table rows (they usually have role="row") and then find the row that contains "Insight 1". + const rows = screen.getAllByRole("row"); + const targetRow = rows.find((row) => row.textContent?.includes("Insight 1")); + + console.log(targetRow?.textContent); + + expect(targetRow).toBeTruthy(); + + // Click the entire row. + fireEvent.click(targetRow!); + + // Wait for the InsightSheet to update. + await waitFor(() => { + const sheet = screen.getAllByTestId("insight-sheet"); + + const matchingSheet = sheet.find((s) => s.textContent?.includes("InsightSheet Open - Insight 1")); + expect(matchingSheet).toBeInTheDocument(); + }); + }); + + test("clicking load more fetches next page of insights", async () => { + // First fetch returns two insights. + mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] }); + // Second fetch returns one additional insight. + mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsightPraise] }); + renderComponent(); + + // Wait for the initial insights to be rendered. + await waitFor(() => { + expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0); + expect(screen.getAllByText("Insight 2").length).toBeGreaterThan(0); + }); + + // The load more button should be visible because hasMore is true. + const loadMoreButton = screen.getAllByText("common.load_more")[0]; + fireEvent.click(loadMoreButton); + + // Wait for the new insight to be appended. + await waitFor(() => { + expect(screen.getAllByText("Praise Insight").length).toBeGreaterThan(0); + }); + }); + + test("changes filter tab and re-fetches insights", async () => { + // For initial active tab "featureRequest", return a featureRequest insight. + mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] }); + renderComponent(); + await waitFor(() => { + expect(screen.getAllByText("Insight 1")[0]).toBeInTheDocument(); + }); + + mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ + data: [dummyInsightComplaint as TInsightWithDocumentCount], + }); + + renderComponent(); + + // Find the complaint tab and click it. + const complaintTab = screen.getAllByText("environments.experience.complaint")[0]; + fireEvent.click(complaintTab); + + // Wait until the new complaint insight is rendered. + await waitFor(() => { + expect(screen.getAllByText("Complaint Insight")[0]).toBeInTheDocument(); + }); + }); + + test("shows loading indicator when fetching insights", async () => { + // Make the mock return a promise that doesn't resolve immediately. + let resolveFetch: any; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + mockGetEnvironmentInsightsAction.mockReturnValueOnce(fetchPromise); + renderComponent(); + + // While fetching, the loading indicator should be visible. + expect(screen.getByTestId("insight-loading")).toBeInTheDocument(); + + // Resolve the fetch. + resolveFetch({ data: [dummyInsight1] }); + await waitFor(() => { + // After fetching, the loading indicator should disappear. + expect(screen.queryByTestId("insight-loading")).not.toBeInTheDocument(); + // Instead of getByText, use getAllByText to assert at least one instance of "Insight 1" exists. + expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/web/modules/ee/insights/experience/lib/insights.ts b/apps/web/modules/ee/insights/experience/lib/insights.ts index 4fdcf8962b..ed26260036 100644 --- a/apps/web/modules/ee/insights/experience/lib/insights.ts +++ b/apps/web/modules/ee/insights/experience/lib/insights.ts @@ -11,6 +11,7 @@ import { cache } from "@formbricks/lib/cache"; import { INSIGHTS_PER_PAGE } from "@formbricks/lib/constants"; import { responseCache } from "@formbricks/lib/response/cache"; import { validateInputs } from "@formbricks/lib/utils/validate"; +import { logger } from "@formbricks/logger"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; @@ -121,7 +122,7 @@ export const updateInsight = async (insightId: string, updates: Partial } } } catch (error) { - console.error("Error in updateInsight:", error); + logger.error(error, "Error in updateInsight"); if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); } diff --git a/apps/web/modules/ee/insights/experience/lib/stats.ts b/apps/web/modules/ee/insights/experience/lib/stats.ts index dc9cbff9ea..f70872d452 100644 --- a/apps/web/modules/ee/insights/experience/lib/stats.ts +++ b/apps/web/modules/ee/insights/experience/lib/stats.ts @@ -6,6 +6,7 @@ import { prisma } from "@formbricks/database"; import { cache } from "@formbricks/lib/cache"; import { responseCache } from "@formbricks/lib/response/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 { TStats } from "../types/stats"; @@ -88,7 +89,7 @@ export const getStats = reactCache( }; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); + logger.error(error, "Error fetching stats"); throw new DatabaseError(error.message); } throw error; diff --git a/apps/web/modules/ee/license-check/lib/utils.ts b/apps/web/modules/ee/license-check/lib/utils.ts index 88beff7ffd..c5ad8cfc8f 100644 --- a/apps/web/modules/ee/license-check/lib/utils.ts +++ b/apps/web/modules/ee/license-check/lib/utils.ts @@ -19,6 +19,7 @@ import { } from "@formbricks/lib/constants"; import { env } from "@formbricks/lib/env"; import { hashString } from "@formbricks/lib/hashString"; +import { logger } from "@formbricks/logger"; const hashedKey = ENTERPRISE_LICENSE_KEY ? hashString(ENTERPRISE_LICENSE_KEY) : undefined; const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const; @@ -98,12 +99,12 @@ const fetchLicenseForE2ETesting = async (): Promise<{ return newResult; } else if (currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000) { // Fail after 1 hour - console.log("E2E_TESTING is enabled. Enterprise license was revoked after 1 hour."); + logger.info("E2E_TESTING is enabled. Enterprise license was revoked after 1 hour."); return null; } return previousResult; } catch (error) { - console.error("Error fetching license: ", error); + logger.error(error, "Error fetching license"); return null; } }; @@ -191,7 +192,7 @@ export const getEnterpriseLicense = async (): Promise<{ } // Log error only after 72 hours - console.error("Error while checking license: The license check failed"); + logger.error("Error while checking license: The license check failed"); return { active: false, @@ -254,7 +255,7 @@ export const fetchLicense = reactCache( return null; } catch (error) { - console.error("Error while checking license: ", error); + logger.error(error, "Error while checking license"); return null; } }, diff --git a/apps/web/modules/ee/role-management/components/add-member-role.tsx b/apps/web/modules/ee/role-management/components/add-member-role.tsx index 3682070ea3..cfba5eb3a2 100644 --- a/apps/web/modules/ee/role-management/components/add-member-role.tsx +++ b/apps/web/modules/ee/role-management/components/add-member-role.tsx @@ -37,7 +37,7 @@ export function AddMemberRole({ let rolesArray = ["member"]; if (isOwner) { - rolesArray.push("owner", "manager"); + rolesArray.push("manager", "owner"); if (isFormbricksCloud) { rolesArray.push("billing"); } @@ -62,7 +62,7 @@ export function AddMemberRole({