mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
Compare commits
45 Commits
fix-hidden
...
v3.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c7f92a4d7 | ||
|
|
c653841037 | ||
|
|
ec314c14ea | ||
|
|
c03e60ac0b | ||
|
|
cbf2343143 | ||
|
|
9d9b3ac543 | ||
|
|
591b35a70b | ||
|
|
f0c7b881d3 | ||
|
|
3fd5515db1 | ||
|
|
f32401afd6 | ||
|
|
1b9d91f1e8 | ||
|
|
1f039d707c | ||
|
|
6671d877ad | ||
|
|
2867c95494 | ||
|
|
aa55cec060 | ||
|
|
dfb6c4cd9e | ||
|
|
a9082f66e8 | ||
|
|
bf39b0fbfb | ||
|
|
e347f2179a | ||
|
|
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 |
@@ -80,6 +80,9 @@ S3_ENDPOINT_URL=
|
|||||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||||
S3_FORCE_PATH_STYLE=0
|
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 #
|
# Disable Features #
|
||||||
#####################
|
#####################
|
||||||
|
|||||||
1
.github/actions/cache-build-web/action.yml
vendored
1
.github/actions/cache-build-web/action.yml
vendored
@@ -56,6 +56,7 @@ runs:
|
|||||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
RANDOM_KEY=$(openssl rand -hex 32)
|
||||||
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||||
shell: bash
|
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
|
|
||||||
@@ -15,7 +15,6 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
IMAGE_NAME: ${{ github.repository }}-experimental
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -80,6 +79,9 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
secrets: |
|
||||||
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/release-docker-github.yml
vendored
4
.github/workflows/release-docker-github.yml
vendored
@@ -19,7 +19,6 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -100,6 +99,9 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
secrets: |
|
||||||
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
|||||||
6
.github/workflows/sonarqube.yml
vendored
6
.github/workflows/sonarqube.yml
vendored
@@ -23,10 +23,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
|
|
||||||
- name: Setup Node.js 20.x
|
- name: Setup Node.js 22.x
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 22.x
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pnpm test:coverage
|
pnpm test:coverage
|
||||||
- name: SonarQube Scan
|
- name: SonarQube Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
|
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
|||||||
34
.github/workflows/terrafrom-plan-and-apply.yml
vendored
34
.github/workflows/terrafrom-plan-and-apply.yml
vendored
@@ -3,16 +3,21 @@ name: 'Terraform'
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# TODO: enable it back when migration is completed.
|
# TODO: enable it back when migration is completed.
|
||||||
# push:
|
push:
|
||||||
# branches:
|
branches:
|
||||||
# - main
|
- main
|
||||||
# pull_request:
|
paths:
|
||||||
# branches:
|
- "infra/terraform/**"
|
||||||
# - main
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "infra/terraform/**"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
terraform:
|
terraform:
|
||||||
@@ -58,18 +63,17 @@ jobs:
|
|||||||
run: terraform plan -out .planfile
|
run: terraform plan -out .planfile
|
||||||
working-directory: infra/terraform
|
working-directory: infra/terraform
|
||||||
|
|
||||||
# - name: Post PR comment
|
- name: Post PR comment
|
||||||
# uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
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:
|
with:
|
||||||
# token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
# planfile: .planfile
|
planfile: .planfile
|
||||||
# working-directory: "infra/terraform"
|
working-directory: "infra/terraform"
|
||||||
# skip-comment: true
|
|
||||||
|
|
||||||
- name: Terraform Apply
|
- name: Terraform Apply
|
||||||
id: 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
|
run: terraform apply .planfile
|
||||||
working-directory: "infra/terraform"
|
working-directory: "infra/terraform"
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"expo-status-bar": "2.0.1",
|
"expo-status-bar": "2.0.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "0.76.6",
|
"react-native": "0.78.2",
|
||||||
"react-native-webview": "13.12.5"
|
"react-native-webview": "13.12.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@formbricks/demo",
|
"name": "@formbricks/demo",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf .turbo node_modules .next",
|
"clean": "rimraf .turbo node_modules .next",
|
||||||
@@ -12,10 +12,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formbricks/js": "workspace:*",
|
"@formbricks/js": "workspace:*",
|
||||||
"lucide-react": "0.468.0",
|
"@tailwindcss/forms": "0.5.9",
|
||||||
"next": "15.2.3",
|
"lucide-react": "0.486.0",
|
||||||
|
"next": "15.2.4",
|
||||||
|
"postcss": "8.5.3",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0",
|
||||||
|
"tailwindcss": "3.4.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formbricks/config-typescript": "workspace:*",
|
"@formbricks/config-typescript": "workspace:*",
|
||||||
|
|||||||
@@ -11,30 +11,30 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eslint-plugin-react-refresh": "0.4.16",
|
"eslint-plugin-react-refresh": "0.4.19",
|
||||||
"react": "19.0.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "3.2.2",
|
"@chromatic-com/storybook": "3.2.6",
|
||||||
"@formbricks/config-typescript": "workspace:*",
|
"@formbricks/config-typescript": "workspace:*",
|
||||||
"@storybook/addon-a11y": "8.4.7",
|
"@storybook/addon-a11y": "8.6.12",
|
||||||
"@storybook/addon-essentials": "8.4.7",
|
"@storybook/addon-essentials": "8.6.12",
|
||||||
"@storybook/addon-interactions": "8.4.7",
|
"@storybook/addon-interactions": "8.6.12",
|
||||||
"@storybook/addon-links": "8.4.7",
|
"@storybook/addon-links": "8.6.12",
|
||||||
"@storybook/addon-onboarding": "8.4.7",
|
"@storybook/addon-onboarding": "8.6.12",
|
||||||
"@storybook/blocks": "8.4.7",
|
"@storybook/blocks": "8.6.12",
|
||||||
"@storybook/react": "8.4.7",
|
"@storybook/react": "8.6.12",
|
||||||
"@storybook/react-vite": "8.4.7",
|
"@storybook/react-vite": "8.6.12",
|
||||||
"@storybook/test": "8.4.7",
|
"@storybook/test": "8.6.12",
|
||||||
"@typescript-eslint/eslint-plugin": "8.18.0",
|
"@typescript-eslint/eslint-plugin": "8.29.0",
|
||||||
"@typescript-eslint/parser": "8.18.0",
|
"@typescript-eslint/parser": "8.29.0",
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
"esbuild": "0.25.1",
|
"esbuild": "0.25.2",
|
||||||
"eslint-plugin-storybook": "0.11.1",
|
"eslint-plugin-storybook": "0.12.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "8.4.7",
|
"storybook": "8.6.12",
|
||||||
"tsup": "8.3.5",
|
"tsup": "8.4.0",
|
||||||
"vite": "6.0.12"
|
"vite": "6.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,27 @@ RUN corepack enable
|
|||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
# 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 SENTRY_AUTH_TOKEN
|
ARG SENTRY_AUTH_TOKEN
|
||||||
|
|
||||||
# Increase Node.js memory limit
|
# Increase Node.js memory limit as a regular build argument
|
||||||
# ENV NODE_OPTIONS="--max_old_space_size=4096"
|
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||||
|
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -47,8 +63,11 @@ RUN touch apps/web/.env
|
|||||||
# Install the dependencies
|
# Install the dependencies
|
||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
|
|
||||||
# Build the project
|
# Build the project using our secret reader script
|
||||||
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
|
# 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
|
# Extract Prisma version
|
||||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const TopControlButtons = ({
|
|||||||
|
|
||||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||||
<Link href="https://github.com/formbricks/formbricks/issues/new/choose" target="_blank">
|
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
|
||||||
<BugIcon />
|
<BugIcon />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
|
|
||||||
|
|
||||||
export default APIKeysLoading;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
|
|
||||||
|
|
||||||
export default APIKeysPage;
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Loading from "@/modules/organization/settings/api-keys/loading";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||||
|
|
||||||
|
export default function LoadingPage() {
|
||||||
|
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||||
|
|
||||||
|
export default APIKeysPage;
|
||||||
@@ -54,6 +54,12 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
hidden: isFormbricksCloud || isPricingDisabled,
|
hidden: isFormbricksCloud || isPricingDisabled,
|
||||||
current: pathname?.includes("/enterprise"),
|
current: pathname?.includes("/enterprise"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "api-keys",
|
||||||
|
label: t("common.api_keys"),
|
||||||
|
href: `/environments/${environmentId}/settings/api-keys`,
|
||||||
|
current: pathname?.includes("/api-keys"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
RESPONSES_PER_PAGE,
|
RESPONSES_PER_PAGE,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@formbricks/lib/constants";
|
} from "@formbricks/lib/constants";
|
||||||
|
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||||
@@ -47,6 +48,7 @@ const Page = async (props) => {
|
|||||||
});
|
});
|
||||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
const surveyDomain = getSurveyDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -57,8 +59,8 @@ const Page = async (props) => {
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
user={user}
|
user={user}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
{isAIEnabled && shouldGenerateInsights && (
|
{isAIEnabled && shouldGenerateInsights && (
|
||||||
|
|||||||
@@ -23,19 +23,19 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
|||||||
|
|
||||||
interface ShareEmbedSurveyProps {
|
interface ShareEmbedSurveyProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
|
surveyDomain: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
modalView: "start" | "embed" | "panel";
|
modalView: "start" | "embed" | "panel";
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
webAppUrl: string;
|
|
||||||
user: TUser;
|
user: TUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareEmbedSurvey = ({
|
export const ShareEmbedSurvey = ({
|
||||||
survey,
|
survey,
|
||||||
|
surveyDomain,
|
||||||
open,
|
open,
|
||||||
modalView,
|
modalView,
|
||||||
setOpen,
|
setOpen,
|
||||||
webAppUrl,
|
|
||||||
user,
|
user,
|
||||||
}: ShareEmbedSurveyProps) => {
|
}: ShareEmbedSurveyProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -104,8 +104,8 @@ export const ShareEmbedSurvey = ({
|
|||||||
<DialogDescription className="hidden" />
|
<DialogDescription className="hidden" />
|
||||||
<ShareSurveyLink
|
<ShareSurveyLink
|
||||||
survey={survey}
|
survey={survey}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
/>
|
/>
|
||||||
@@ -159,8 +159,8 @@ export const ShareEmbedSurvey = ({
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
email={email}
|
email={email}
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
/>
|
/>
|
||||||
) : showView === "panel" ? (
|
) : showView === "panel" ? (
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ interface SurveyAnalysisCTAProps {
|
|||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
webAppUrl: string;
|
|
||||||
user: TUser;
|
user: TUser;
|
||||||
|
surveyDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModalState {
|
interface ModalState {
|
||||||
@@ -35,8 +35,8 @@ export const SurveyAnalysisCTA = ({
|
|||||||
survey,
|
survey,
|
||||||
environment,
|
environment,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
webAppUrl,
|
|
||||||
user,
|
user,
|
||||||
|
surveyDomain,
|
||||||
}: SurveyAnalysisCTAProps) => {
|
}: SurveyAnalysisCTAProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -50,7 +50,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
dropdown: false,
|
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 { refreshSingleUseId } = useSingleUseId(survey);
|
||||||
|
|
||||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
@@ -172,9 +172,9 @@ export const SurveyAnalysisCTA = ({
|
|||||||
<ShareEmbedSurvey
|
<ShareEmbedSurvey
|
||||||
key={key}
|
key={key}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
open={modalState[key as keyof ModalState]}
|
open={modalState[key as keyof ModalState]}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
user={user}
|
user={user}
|
||||||
modalView={modalView}
|
modalView={modalView}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ interface EmbedViewProps {
|
|||||||
survey: any;
|
survey: any;
|
||||||
email: string;
|
email: string;
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
|
surveyDomain: string;
|
||||||
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||||
webAppUrl: string;
|
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ export const EmbedView = ({
|
|||||||
survey,
|
survey,
|
||||||
email,
|
email,
|
||||||
surveyUrl,
|
surveyUrl,
|
||||||
|
surveyDomain,
|
||||||
setSurveyUrl,
|
setSurveyUrl,
|
||||||
webAppUrl,
|
|
||||||
locale,
|
locale,
|
||||||
}: EmbedViewProps) => {
|
}: EmbedViewProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -82,8 +82,8 @@ export const EmbedView = ({
|
|||||||
) : activeId === "link" ? (
|
) : activeId === "link" ? (
|
||||||
<LinkTab
|
<LinkTab
|
||||||
survey={survey}
|
survey={survey}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import { TUserLocale } from "@formbricks/types/user";
|
|||||||
|
|
||||||
interface LinkTabProps {
|
interface LinkTabProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
webAppUrl: string;
|
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
|
surveyDomain: string;
|
||||||
setSurveyUrl: (url: string) => void;
|
setSurveyUrl: (url: string) => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
|
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const docsLinks = [
|
const docsLinks = [
|
||||||
@@ -43,8 +43,8 @@ export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }:
|
|||||||
</p>
|
</p>
|
||||||
<ShareSurveyLink
|
<ShareSurveyLink
|
||||||
survey={survey}
|
survey={survey}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const dummySurvey = {
|
|||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
||||||
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
||||||
const webAppUrl = "http://example.com";
|
const surveyDomain = "https://surveys.test.formbricks.com";
|
||||||
|
|
||||||
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -91,7 +91,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
|||||||
survey={dummySurvey}
|
survey={dummySurvey}
|
||||||
environment={dummyEnvironment}
|
environment={dummyEnvironment}
|
||||||
isReadOnly={false}
|
isReadOnly={false}
|
||||||
webAppUrl={webAppUrl}
|
surveyDomain={surveyDomain}
|
||||||
user={dummyUser}
|
user={dummyUser}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -101,7 +101,9 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||||
expect(writeTextMock).toHaveBeenCalledWith("http://example.com/s/survey123?id=newSingleUseId");
|
expect(writeTextMock).toHaveBeenCalledWith(
|
||||||
|
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
|
||||||
|
);
|
||||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -113,7 +115,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
|||||||
survey={dummySurvey}
|
survey={dummySurvey}
|
||||||
environment={dummyEnvironment}
|
environment={dummyEnvironment}
|
||||||
isReadOnly={false}
|
isReadOnly={false}
|
||||||
webAppUrl={webAppUrl}
|
surveyDomain={surveyDomain}
|
||||||
user={dummyUser}
|
user={dummyUser}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
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 { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { getStyling } from "@formbricks/lib/utils/styling";
|
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 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 html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||||
const doctype =
|
const doctype =
|
||||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
|
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@formbricks/lib/constants";
|
} from "@formbricks/lib/constants";
|
||||||
|
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { getUser } from "@formbricks/lib/user/service";
|
import { getUser } from "@formbricks/lib/user/service";
|
||||||
@@ -54,6 +55,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
billing: organization.billing,
|
billing: organization.billing,
|
||||||
});
|
});
|
||||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||||
|
const surveyDomain = getSurveyDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -64,8 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
user={user}
|
user={user}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
{isAIEnabled && shouldGenerateInsights && (
|
{isAIEnabled && shouldGenerateInsights && (
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
|||||||
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
|
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
|
||||||
NoMobileOverlay: () => <div data-testid="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", () => ({
|
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||||
ToasterClient: () => <div data-testid="toaster-client" />,
|
ToasterClient: () => <div data-testid="toaster-client" />,
|
||||||
}));
|
}));
|
||||||
@@ -74,8 +68,6 @@ describe("(app) AppLayout", () => {
|
|||||||
render(element);
|
render(element);
|
||||||
|
|
||||||
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
|
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("mock-intercom-wrapper")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
|
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
||||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||||
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
@@ -13,6 +14,11 @@ const AppLayout = async ({ children }) => {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
|
// If user account is deactivated, log them out instead of rendering the app
|
||||||
|
if (user?.isActive === false) {
|
||||||
|
return <ClientLogout />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
|
|||||||
@@ -1,51 +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";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
|
|
||||||
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) {
|
|
||||||
logger.error({ error: err, url: request.url }, "Failed to convert to CSV");
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
178
apps/web/app/api/v1/auth.test.ts
Normal file
178
apps/web/app/api/v1/auth.test.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||||
|
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||||
|
import { authenticateRequest } from "./auth";
|
||||||
|
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
apiKey: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||||
|
hashApiKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getApiKeyWithPermissions", () => {
|
||||||
|
it("should return API key data with permissions when valid key is provided", async () => {
|
||||||
|
const mockApiKeyData = {
|
||||||
|
id: "api-key-id",
|
||||||
|
organizationId: "org-id",
|
||||||
|
hashedKey: "hashed-key",
|
||||||
|
createdAt: new Date(),
|
||||||
|
createdBy: "user-id",
|
||||||
|
lastUsedAt: null,
|
||||||
|
label: "Test API Key",
|
||||||
|
apiKeyEnvironments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-1",
|
||||||
|
permission: "manage" as const,
|
||||||
|
environment: { id: "env-1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||||
|
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||||
|
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||||
|
|
||||||
|
const result = await getApiKeyWithPermissions("test-api-key");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockApiKeyData);
|
||||||
|
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "api-key-id" },
|
||||||
|
data: { lastUsedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when API key is not found", async () => {
|
||||||
|
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await getApiKeyWithPermissions("invalid-key");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPermission", () => {
|
||||||
|
const permissions: TAPIKeyEnvironmentPermission[] = [
|
||||||
|
{
|
||||||
|
environmentId: "env-1",
|
||||||
|
permission: "manage",
|
||||||
|
environmentType: "development",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectName: "Project 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-2",
|
||||||
|
permission: "write",
|
||||||
|
environmentType: "production",
|
||||||
|
projectId: "project-2",
|
||||||
|
projectName: "Project 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-3",
|
||||||
|
permission: "read",
|
||||||
|
environmentType: "development",
|
||||||
|
projectId: "project-3",
|
||||||
|
projectName: "Project 3",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("should return true for manage permission with any method", () => {
|
||||||
|
expect(hasPermission(permissions, "env-1", "GET")).toBe(true);
|
||||||
|
expect(hasPermission(permissions, "env-1", "POST")).toBe(true);
|
||||||
|
expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle write permission correctly", () => {
|
||||||
|
expect(hasPermission(permissions, "env-2", "GET")).toBe(true);
|
||||||
|
expect(hasPermission(permissions, "env-2", "POST")).toBe(true);
|
||||||
|
expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle read permission correctly", () => {
|
||||||
|
expect(hasPermission(permissions, "env-3", "GET")).toBe(true);
|
||||||
|
expect(hasPermission(permissions, "env-3", "POST")).toBe(false);
|
||||||
|
expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for non-existent environment", () => {
|
||||||
|
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticateRequest", () => {
|
||||||
|
it("should return authentication data for valid API key", async () => {
|
||||||
|
const request = new Request("http://localhost", {
|
||||||
|
headers: { "x-api-key": "valid-api-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockApiKeyData = {
|
||||||
|
id: "api-key-id",
|
||||||
|
organizationId: "org-id",
|
||||||
|
hashedKey: "hashed-key",
|
||||||
|
createdAt: new Date(),
|
||||||
|
createdBy: "user-id",
|
||||||
|
lastUsedAt: null,
|
||||||
|
label: "Test API Key",
|
||||||
|
apiKeyEnvironments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-1",
|
||||||
|
permission: "manage" as const,
|
||||||
|
environment: {
|
||||||
|
id: "env-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
project: { name: "Project 1" },
|
||||||
|
type: "development",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||||
|
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||||
|
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||||
|
|
||||||
|
const result = await authenticateRequest(request);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: "apiKey",
|
||||||
|
environmentPermissions: [
|
||||||
|
{
|
||||||
|
environmentId: "env-1",
|
||||||
|
permission: "manage",
|
||||||
|
environmentType: "development",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectName: "Project 1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashedApiKey: "hashed-key",
|
||||||
|
apiKeyId: "api-key-id",
|
||||||
|
organizationId: "org-id",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when no API key is provided", async () => {
|
||||||
|
const request = new Request("http://localhost");
|
||||||
|
const result = await authenticateRequest(request);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when API key is invalid", async () => {
|
||||||
|
const request = new Request("http://localhost", {
|
||||||
|
headers: { "x-api-key": "invalid-api-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await authenticateRequest(request);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,38 @@
|
|||||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||||
|
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
|
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
|
||||||
const apiKey = request.headers.get("x-api-key");
|
const apiKey = request.headers.get("x-api-key");
|
||||||
if (apiKey) {
|
if (!apiKey) return null;
|
||||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
|
||||||
if (environmentId) {
|
// Get API key with permissions
|
||||||
const hashedApiKey = hashApiKey(apiKey);
|
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||||
const authentication: TAuthenticationApiKey = {
|
if (!apiKeyData) return null;
|
||||||
type: "apiKey",
|
|
||||||
environmentId,
|
// In the route handlers, we'll do more specific permission checks
|
||||||
hashedApiKey,
|
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||||
};
|
if (environmentIds.length === 0) return null;
|
||||||
return authentication;
|
|
||||||
}
|
const hashedApiKey = hashApiKey(apiKey);
|
||||||
return null;
|
const authentication: TAuthenticationApiKey = {
|
||||||
}
|
type: "apiKey",
|
||||||
return null;
|
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||||
|
environmentId: env.environmentId,
|
||||||
|
environmentType: env.environment.type,
|
||||||
|
permission: env.permission,
|
||||||
|
projectId: env.environment.projectId,
|
||||||
|
projectName: env.environment.project.name,
|
||||||
|
})),
|
||||||
|
hashedApiKey,
|
||||||
|
apiKeyId: apiKeyData.id,
|
||||||
|
organizationId: apiKeyData.organizationId,
|
||||||
|
organizationAccess: apiKeyData.organizationAccess,
|
||||||
|
};
|
||||||
|
|
||||||
|
return authentication;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleErrorResponse = (error: any): Response => {
|
export const handleErrorResponse = (error: any): Response => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
OPTIONS,
|
OPTIONS,
|
||||||
PUT,
|
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 };
|
export { OPTIONS, PUT };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
GET,
|
GET,
|
||||||
OPTIONS,
|
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 };
|
export { GET, OPTIONS };
|
||||||
|
|||||||
@@ -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 };
|
export { POST, OPTIONS };
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { cache as reactCache } from "react";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { cache } from "@formbricks/lib/cache";
|
|
||||||
import { getHash } from "@formbricks/lib/crypto";
|
|
||||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
|
||||||
import { ZString } from "@formbricks/types/common";
|
|
||||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
|
|
||||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
|
|
||||||
const hashedKey = getHash(apiKey);
|
|
||||||
return cache(
|
|
||||||
async () => {
|
|
||||||
validateInputs([apiKey, ZString]);
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new InvalidInputError("API key cannot be null or undefined.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKeyData = await prisma.apiKey.findUnique({
|
|
||||||
where: {
|
|
||||||
hashedKey,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
environmentId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!apiKeyData) {
|
|
||||||
throw new ResourceNotFoundError("apiKey", apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiKeyData.environmentId;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
throw new DatabaseError(error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
|
|
||||||
{
|
|
||||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||||
@@ -8,15 +9,20 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
|||||||
|
|
||||||
const fetchAndAuthorizeActionClass = async (
|
const fetchAndAuthorizeActionClass = async (
|
||||||
authentication: TAuthenticationApiKey,
|
authentication: TAuthenticationApiKey,
|
||||||
actionClassId: string
|
actionClassId: string,
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE"
|
||||||
): Promise<TActionClass | null> => {
|
): Promise<TActionClass | null> => {
|
||||||
|
// Get the action class
|
||||||
const actionClass = await getActionClass(actionClassId);
|
const actionClass = await getActionClass(actionClassId);
|
||||||
if (!actionClass) {
|
if (!actionClass) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (actionClass.environmentId !== authentication.environmentId) {
|
|
||||||
|
// Check if API key has permission to access this environment with appropriate permissions
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, actionClass.environmentId, method)) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
return actionClass;
|
return actionClass;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,7 +34,7 @@ export const GET = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
|
||||||
if (actionClass) {
|
if (actionClass) {
|
||||||
return responses.successResponse(actionClass);
|
return responses.successResponse(actionClass);
|
||||||
}
|
}
|
||||||
@@ -46,7 +52,7 @@ export const PUT = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
|
||||||
if (!actionClass) {
|
if (!actionClass) {
|
||||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||||
}
|
}
|
||||||
@@ -88,7 +94,7 @@ export const DELETE = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
|
||||||
if (!actionClass) {
|
if (!actionClass) {
|
||||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
import { getActionClasses } from "./action-classes";
|
||||||
|
|
||||||
|
// Mock the prisma client
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
actionClass: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getActionClasses", () => {
|
||||||
|
const mockEnvironmentIds = ["env1", "env2"];
|
||||||
|
const mockActionClasses = [
|
||||||
|
{
|
||||||
|
id: "action1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: "Test Action 1",
|
||||||
|
description: "Test Description 1",
|
||||||
|
type: "click",
|
||||||
|
key: "test-key-1",
|
||||||
|
noCodeConfig: {},
|
||||||
|
environmentId: "env1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action2",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: "Test Action 2",
|
||||||
|
description: "Test Description 2",
|
||||||
|
type: "pageview",
|
||||||
|
key: "test-key-2",
|
||||||
|
noCodeConfig: {},
|
||||||
|
environmentId: "env2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully fetch action classes for given environment IDs", async () => {
|
||||||
|
// Mock the prisma findMany response
|
||||||
|
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
|
||||||
|
|
||||||
|
const result = await getActionClasses(mockEnvironmentIds);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockActionClasses);
|
||||||
|
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
environmentId: { in: mockEnvironmentIds },
|
||||||
|
},
|
||||||
|
select: expect.any(Object),
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw DatabaseError when prisma query fails", async () => {
|
||||||
|
// Mock the prisma findMany to throw an error
|
||||||
|
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
|
await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty environment IDs array", async () => {
|
||||||
|
// Mock the prisma findMany response
|
||||||
|
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await getActionClasses([]);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
environmentId: { in: [] },
|
||||||
|
},
|
||||||
|
select: expect.any(Object),
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { cache as reactCache } from "react";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
|
||||||
|
import { cache } from "@formbricks/lib/cache";
|
||||||
|
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||||
|
import { TActionClass } from "@formbricks/types/action-classes";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
|
const selectActionClass = {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
type: true,
|
||||||
|
key: true,
|
||||||
|
noCodeConfig: true,
|
||||||
|
environmentId: true,
|
||||||
|
} satisfies Prisma.ActionClassSelect;
|
||||||
|
|
||||||
|
export const getActionClasses = reactCache(
|
||||||
|
async (environmentIds: string[]): Promise<TActionClass[]> =>
|
||||||
|
cache(
|
||||||
|
async () => {
|
||||||
|
validateInputs([environmentIds, ZId.array()]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.actionClass.findMany({
|
||||||
|
where: {
|
||||||
|
environmentId: { in: environmentIds },
|
||||||
|
},
|
||||||
|
select: selectActionClass,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`),
|
||||||
|
{
|
||||||
|
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)),
|
||||||
|
}
|
||||||
|
)()
|
||||||
|
);
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
|
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
import { getActionClasses } from "./lib/action-classes";
|
||||||
|
|
||||||
export const GET = async (request: Request) => {
|
export const GET = async (request: Request) => {
|
||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const actionClasses: TActionClass[] = await getActionClasses(authentication.environmentId!);
|
|
||||||
|
const environmentIds = authentication.environmentPermissions.map(
|
||||||
|
(permission) => permission.environmentId
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionClasses = await getActionClasses(environmentIds);
|
||||||
|
|
||||||
return responses.successResponse(actionClasses);
|
return responses.successResponse(actionClasses);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
@@ -35,6 +43,12 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
|
|
||||||
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
||||||
|
|
||||||
|
const environmentId = actionClassInput.environmentId;
|
||||||
|
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||||
|
return responses.unauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
if (!inputValidation.success) {
|
if (!inputValidation.success) {
|
||||||
return responses.badRequestResponse(
|
return responses.badRequestResponse(
|
||||||
"Fields are missing or incorrectly formatted",
|
"Fields are missing or incorrectly formatted",
|
||||||
@@ -43,10 +57,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionClass: TActionClass = await createActionClass(
|
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
|
||||||
authentication.environmentId!,
|
|
||||||
inputValidation.data
|
|
||||||
);
|
|
||||||
return responses.successResponse(actionClass);
|
return responses.successResponse(actionClass);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import {
|
|||||||
DELETE,
|
DELETE,
|
||||||
GET,
|
GET,
|
||||||
PUT,
|
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 };
|
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 };
|
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 };
|
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 };
|
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 };
|
export { GET };
|
||||||
|
|
||||||
|
|||||||
@@ -12,29 +12,56 @@ export const GET = async () => {
|
|||||||
hashedKey: hashApiKey(apiKey),
|
hashedKey: hashApiKey(apiKey),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
environment: {
|
apiKeyEnvironments: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
environment: {
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
type: true,
|
|
||||||
project: {
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
type: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
projectId: true,
|
||||||
|
widgetSetupCompleted: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
appSetupCompleted: true,
|
permission: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!apiKeyData) {
|
if (!apiKeyData) {
|
||||||
return new Response("Not authenticated", {
|
return new Response("Not authenticated", {
|
||||||
status: 401,
|
status: 401,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Response.json(apiKeyData.environment);
|
|
||||||
|
if (
|
||||||
|
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||||
|
apiKeyData.apiKeyEnvironments[0].permission === "manage"
|
||||||
|
) {
|
||||||
|
return Response.json({
|
||||||
|
id: apiKeyData.apiKeyEnvironments[0].environment.id,
|
||||||
|
type: apiKeyData.apiKeyEnvironments[0].environment.type,
|
||||||
|
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
|
||||||
|
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
|
||||||
|
widgetSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.widgetSetupCompleted,
|
||||||
|
project: {
|
||||||
|
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
|
||||||
|
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return new Response("You can't use this method with this API key", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const sessionUser = await getSessionUser();
|
const sessionUser = await getSessionUser();
|
||||||
if (!sessionUser) {
|
if (!sessionUser) {
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
|
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses";
|
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||||
|
|
||||||
const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise<TResponse> => {
|
async function fetchAndAuthorizeResponse(
|
||||||
|
responseId: string,
|
||||||
|
authentication: any,
|
||||||
|
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||||
|
) {
|
||||||
const response = await getResponse(responseId);
|
const response = await getResponse(responseId);
|
||||||
if (!response || !(await canUserAccessResponse(authentication, response))) {
|
if (!response) {
|
||||||
throw new Error("Unauthorized");
|
return { error: responses.notFoundResponse("Response", responseId) };
|
||||||
}
|
}
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canUserAccessResponse = async (authentication: any, response: TResponse): Promise<boolean> => {
|
|
||||||
const survey = await getSurvey(response.surveyId);
|
const survey = await getSurvey(response.surveyId);
|
||||||
if (!survey) return false;
|
if (!survey) {
|
||||||
|
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
|
||||||
if (authentication.type === "session") {
|
|
||||||
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
|
|
||||||
} else if (authentication.type === "apiKey") {
|
|
||||||
return survey.environmentId === authentication.environmentId;
|
|
||||||
} else {
|
|
||||||
throw Error("Unknown authentication type");
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
|
||||||
|
return { error: responses.unauthorizedResponse() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { response };
|
||||||
|
}
|
||||||
|
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -36,11 +37,11 @@ export const GET = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const response = await fetchAndValidateResponse(authentication, params.responseId);
|
|
||||||
if (response) {
|
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
|
||||||
return responses.successResponse(response);
|
if (result.error) return result.error;
|
||||||
}
|
|
||||||
return responses.notFoundResponse("Response", params.responseId);
|
return responses.successResponse(result.response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleErrorResponse(error);
|
return handleErrorResponse(error);
|
||||||
}
|
}
|
||||||
@@ -54,10 +55,10 @@ export const DELETE = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const response = await fetchAndValidateResponse(authentication, params.responseId);
|
|
||||||
if (!response) {
|
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
|
||||||
return responses.notFoundResponse("Response", params.responseId);
|
if (result.error) return result.error;
|
||||||
}
|
|
||||||
const deletedResponse = await deleteResponse(params.responseId);
|
const deletedResponse = await deleteResponse(params.responseId);
|
||||||
return responses.successResponse(deletedResponse);
|
return responses.successResponse(deletedResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,7 +74,10 @@ export const PUT = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
await fetchAndValidateResponse(authentication, params.responseId);
|
|
||||||
|
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
|
||||||
|
if (result.error) return result.error;
|
||||||
|
|
||||||
let responseUpdate;
|
let responseUpdate;
|
||||||
try {
|
try {
|
||||||
responseUpdate = await request.json();
|
responseUpdate = await request.json();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { cache } from "@formbricks/lib/cache";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||||
import {
|
import {
|
||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
@@ -8,11 +10,13 @@ import {
|
|||||||
} from "@formbricks/lib/organization/service";
|
} from "@formbricks/lib/organization/service";
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||||
import { responseCache } from "@formbricks/lib/response/cache";
|
import { responseCache } from "@formbricks/lib/response/cache";
|
||||||
|
import { getResponseContact } from "@formbricks/lib/response/service";
|
||||||
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
|
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
|
||||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
@@ -25,6 +29,7 @@ export const responseSelection = {
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
surveyId: true,
|
surveyId: true,
|
||||||
finished: true,
|
finished: true,
|
||||||
|
endingId: true,
|
||||||
data: true,
|
data: true,
|
||||||
meta: true,
|
meta: true,
|
||||||
ttc: true,
|
ttc: true,
|
||||||
@@ -193,3 +198,53 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getResponsesByEnvironmentIds = reactCache(
|
||||||
|
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
|
||||||
|
cache(
|
||||||
|
async () => {
|
||||||
|
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||||
|
try {
|
||||||
|
const responses = await prisma.response.findMany({
|
||||||
|
where: {
|
||||||
|
survey: {
|
||||||
|
environmentId: { in: environmentIds },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: responseSelection,
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
take: limit ? limit : undefined,
|
||||||
|
skip: offset ? offset : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedResponses: TResponse[] = await Promise.all(
|
||||||
|
responses.map((responsePrisma) => {
|
||||||
|
return {
|
||||||
|
...responsePrisma,
|
||||||
|
contact: getResponseContact(responsePrisma),
|
||||||
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return transformedResponses;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
throw new DatabaseError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
environmentIds.map(
|
||||||
|
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
|
||||||
|
),
|
||||||
|
{
|
||||||
|
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
|
||||||
|
}
|
||||||
|
)()
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
|
import { getResponses } from "@formbricks/lib/response/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { createResponse } from "./lib/response";
|
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||||
|
|
||||||
export const GET = async (request: NextRequest) => {
|
export const GET = async (request: NextRequest) => {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@@ -18,14 +19,26 @@ export const GET = async (request: NextRequest) => {
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
let environmentResponses: TResponse[] = [];
|
let allResponses: TResponse[] = [];
|
||||||
|
|
||||||
if (surveyId) {
|
if (surveyId) {
|
||||||
environmentResponses = await getResponses(surveyId, limit, offset);
|
const survey = await getSurvey(surveyId);
|
||||||
|
if (!survey) {
|
||||||
|
return responses.notFoundResponse("Survey", surveyId, true);
|
||||||
|
}
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||||
|
return responses.unauthorizedResponse();
|
||||||
|
}
|
||||||
|
const surveyResponses = await getResponses(surveyId, limit, offset);
|
||||||
|
allResponses.push(...surveyResponses);
|
||||||
} else {
|
} else {
|
||||||
environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset);
|
const environmentIds = authentication.environmentPermissions.map(
|
||||||
|
(permission) => permission.environmentId
|
||||||
|
);
|
||||||
|
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
|
||||||
|
allResponses.push(...environmentResponses);
|
||||||
}
|
}
|
||||||
return responses.successResponse(environmentResponses);
|
return responses.successResponse(allResponses);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
return responses.badRequestResponse(error.message);
|
return responses.badRequestResponse(error.message);
|
||||||
@@ -39,8 +52,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
|
|
||||||
const environmentId = authentication.environmentId;
|
|
||||||
|
|
||||||
let jsonInput;
|
let jsonInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -50,9 +61,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||||
}
|
}
|
||||||
|
|
||||||
// add environmentId to response
|
|
||||||
jsonInput.environmentId = environmentId;
|
|
||||||
|
|
||||||
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
||||||
|
|
||||||
if (!inputValidation.success) {
|
if (!inputValidation.success) {
|
||||||
@@ -65,6 +73,12 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
|
|
||||||
const responseInput = inputValidation.data;
|
const responseInput = inputValidation.data;
|
||||||
|
|
||||||
|
const environmentId = responseInput.environmentId;
|
||||||
|
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||||
|
return responses.unauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
// get and check survey
|
// get and check survey
|
||||||
const survey = await getSurvey(responseInput.surveyId);
|
const survey = await getSurvey(responseInput.surveyId);
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
|
|||||||
@@ -3,21 +3,28 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||||
|
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise<TSurvey | null> => {
|
const fetchAndAuthorizeSurvey = async (
|
||||||
|
surveyId: string,
|
||||||
|
authentication: TAuthenticationApiKey,
|
||||||
|
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||||
|
) => {
|
||||||
const survey = await getSurvey(surveyId);
|
const survey = await getSurvey(surveyId);
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
return null;
|
return { error: responses.notFoundResponse("Survey", surveyId) };
|
||||||
}
|
}
|
||||||
if (survey.environmentId !== authentication.environmentId) {
|
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
|
||||||
throw new Error("Unauthorized");
|
return { error: responses.unauthorizedResponse() };
|
||||||
}
|
}
|
||||||
return survey;
|
|
||||||
|
return { survey };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
@@ -28,11 +35,9 @@ export const GET = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
|
||||||
if (survey) {
|
if (result.error) return result.error;
|
||||||
return responses.successResponse(survey);
|
return responses.successResponse(result.survey);
|
||||||
}
|
|
||||||
return responses.notFoundResponse("Survey", params.surveyId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleErrorResponse(error);
|
return handleErrorResponse(error);
|
||||||
}
|
}
|
||||||
@@ -46,10 +51,8 @@ export const DELETE = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
|
||||||
if (!survey) {
|
if (result.error) return result.error;
|
||||||
return responses.notFoundResponse("Survey", params.surveyId);
|
|
||||||
}
|
|
||||||
const deletedSurvey = await deleteSurvey(params.surveyId);
|
const deletedSurvey = await deleteSurvey(params.surveyId);
|
||||||
return responses.successResponse(deletedSurvey);
|
return responses.successResponse(deletedSurvey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,13 +68,10 @@ export const PUT = async (
|
|||||||
try {
|
try {
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
|
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
|
||||||
|
if (result.error) return result.error;
|
||||||
|
|
||||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
|
||||||
if (!survey) {
|
|
||||||
return responses.notFoundResponse("Survey", params.surveyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return responses.notFoundResponse("Organization", null);
|
return responses.notFoundResponse("Organization", null);
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ export const PUT = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputValidation = ZSurveyUpdateInput.safeParse({
|
const inputValidation = ZSurveyUpdateInput.safeParse({
|
||||||
...survey,
|
...result.survey,
|
||||||
...surveyUpdate,
|
...surveyUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys";
|
import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys";
|
||||||
|
|
||||||
@@ -16,8 +18,8 @@ export const GET = async (
|
|||||||
if (!survey) {
|
if (!survey) {
|
||||||
return responses.notFoundResponse("Survey", params.surveyId);
|
return responses.notFoundResponse("Survey", params.surveyId);
|
||||||
}
|
}
|
||||||
if (survey.environmentId !== authentication.environmentId) {
|
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||||
throw new Error("Unauthorized");
|
return responses.unauthorizedResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!survey.singleUse || !survey.singleUse.enabled) {
|
if (!survey.singleUse || !survey.singleUse.enabled) {
|
||||||
@@ -36,9 +38,10 @@ export const GET = async (
|
|||||||
|
|
||||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||||
|
|
||||||
|
const surveyDomain = getSurveyDomain();
|
||||||
// map single use ids to survey links
|
// map single use ids to survey links
|
||||||
const surveyLinks = singleUseIds.map(
|
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);
|
return responses.successResponse(surveyLinks);
|
||||||
|
|||||||
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal file
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { cache as reactCache } from "react";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { cache } from "@formbricks/lib/cache";
|
||||||
|
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 { ZOptionalNumber } from "@formbricks/types/common";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
|
export const getSurveys = reactCache(
|
||||||
|
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> =>
|
||||||
|
cache(
|
||||||
|
async () => {
|
||||||
|
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const surveysPrisma = await prisma.survey.findMany({
|
||||||
|
where: {
|
||||||
|
environmentId: { in: environmentIds },
|
||||||
|
},
|
||||||
|
select: selectSurvey,
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
logger.error(error, "Error getting surveys");
|
||||||
|
throw new DatabaseError(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`),
|
||||||
|
{
|
||||||
|
tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)),
|
||||||
|
}
|
||||||
|
)()
|
||||||
|
);
|
||||||
@@ -2,12 +2,14 @@ import { authenticateRequest } from "@/app/api/v1/auth";
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||||
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
|
import { createSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||||
|
import { getSurveys } from "./lib/surveys";
|
||||||
|
|
||||||
export const GET = async (request: Request) => {
|
export const GET = async (request: Request) => {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +20,11 @@ export const GET = async (request: Request) => {
|
|||||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||||
|
|
||||||
const surveys = await getSurveys(authentication.environmentId!, limit, offset);
|
const environmentIds = authentication.environmentPermissions.map(
|
||||||
|
(permission) => permission.environmentId
|
||||||
|
);
|
||||||
|
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||||
|
|
||||||
return responses.successResponse(surveys);
|
return responses.successResponse(surveys);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
@@ -33,11 +39,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
|
|
||||||
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
|
|
||||||
if (!organization) {
|
|
||||||
return responses.notFoundResponse("Organization", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let surveyInput;
|
let surveyInput;
|
||||||
try {
|
try {
|
||||||
surveyInput = await request.json();
|
surveyInput = await request.json();
|
||||||
@@ -45,8 +46,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||||
}
|
}
|
||||||
|
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
||||||
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
|
|
||||||
|
|
||||||
if (!inputValidation.success) {
|
if (!inputValidation.success) {
|
||||||
return responses.badRequestResponse(
|
return responses.badRequestResponse(
|
||||||
@@ -56,8 +56,18 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const environmentId = authentication.environmentId;
|
const environmentId = inputValidation.data.environmentId;
|
||||||
const surveyData = { ...inputValidation.data, environmentId: undefined };
|
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||||
|
return responses.unauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||||
|
if (!organization) {
|
||||||
|
return responses.notFoundResponse("Organization", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const surveyData = { ...inputValidation.data, environmentId };
|
||||||
|
|
||||||
if (surveyData.followUps?.length) {
|
if (surveyData.followUps?.length) {
|
||||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||||
@@ -73,7 +83,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const survey = await createSurvey(environmentId, surveyData);
|
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
|
||||||
return responses.successResponse(survey);
|
return responses.successResponse(survey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
|
|||||||
@@ -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">
|
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||||
{name}
|
{name}
|
||||||
</h2>
|
</h2>
|
||||||
<span tw="text-slate-600 text-xl">Complete in ~ 4 minutes</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div tw="flex justify-end mr-10 ">
|
<div tw="flex justify-end mr-10 ">
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||||
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
|
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
const apiKey = headersList.get("x-api-key");
|
const apiKey = headersList.get("x-api-key");
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return responses.notAuthenticatedResponse();
|
return responses.notAuthenticatedResponse();
|
||||||
}
|
}
|
||||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!environmentId) {
|
if (!authentication) {
|
||||||
return responses.notAuthenticatedResponse();
|
return responses.notAuthenticatedResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
|
|||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||||
}
|
}
|
||||||
if (webhook.environmentId !== environmentId) {
|
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
|
||||||
return responses.unauthorizedResponse();
|
return responses.unauthorizedResponse();
|
||||||
}
|
}
|
||||||
return responses.successResponse(webhook);
|
return responses.successResponse(webhook);
|
||||||
@@ -34,8 +35,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
|
|||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return responses.notAuthenticatedResponse();
|
return responses.notAuthenticatedResponse();
|
||||||
}
|
}
|
||||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!environmentId) {
|
if (!authentication) {
|
||||||
return responses.notAuthenticatedResponse();
|
return responses.notAuthenticatedResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
|
|||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||||
}
|
}
|
||||||
if (webhook.environmentId !== environmentId) {
|
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
|
||||||
return responses.unauthorizedResponse();
|
return responses.unauthorizedResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
|
|||||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||||
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
|
validateInputs([webhookInput, ZWebhookInput]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createdWebhook = await prisma.webhook.create({
|
const createdWebhook = await prisma.webhook.create({
|
||||||
data: {
|
data: {
|
||||||
...webhookInput,
|
url: webhookInput.url,
|
||||||
|
name: webhookInput.name,
|
||||||
|
source: webhookInput.source,
|
||||||
surveyIds: webhookInput.surveyIds || [],
|
surveyIds: webhookInput.surveyIds || [],
|
||||||
|
triggers: webhookInput.triggers || [],
|
||||||
environment: {
|
environment: {
|
||||||
connect: {
|
connect: {
|
||||||
id: environmentId,
|
id: webhookInput.environmentId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -37,22 +40,24 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(error instanceof InvalidInputError)) {
|
if (!(error instanceof InvalidInputError)) {
|
||||||
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
|
throw new DatabaseError(
|
||||||
|
`Database error when creating webhook for environment ${webhookInput.environmentId}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> =>
|
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
|
||||||
cache(
|
cache(
|
||||||
async () => {
|
async () => {
|
||||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const webhooks = await prisma.webhook.findMany({
|
const webhooks = await prisma.webhook.findMany({
|
||||||
where: {
|
where: {
|
||||||
environmentId: environmentId,
|
environmentId: { in: environmentIds },
|
||||||
},
|
},
|
||||||
take: page ? ITEMS_PER_PAGE : undefined,
|
take: page ? ITEMS_PER_PAGE : undefined,
|
||||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||||
@@ -66,8 +71,8 @@ export const getWebhooks = (environmentId: string, page?: number): Promise<Webho
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[`getWebhooks-${environmentId}-${page}`],
|
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
|
||||||
{
|
{
|
||||||
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
|
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
|
||||||
}
|
}
|
||||||
)();
|
)();
|
||||||
|
|||||||
@@ -1,42 +1,33 @@
|
|||||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||||
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
|
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
|
||||||
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { headers } from "next/headers";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
export const GET = async () => {
|
export const GET = async (request: Request) => {
|
||||||
const headersList = await headers();
|
const authentication = await authenticateRequest(request);
|
||||||
const apiKey = headersList.get("x-api-key");
|
if (!authentication) {
|
||||||
if (!apiKey) {
|
|
||||||
return responses.notAuthenticatedResponse();
|
return responses.notAuthenticatedResponse();
|
||||||
}
|
}
|
||||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
|
||||||
if (!environmentId) {
|
|
||||||
return responses.notAuthenticatedResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
// get webhooks from database
|
|
||||||
try {
|
try {
|
||||||
const webhooks = await getWebhooks(environmentId);
|
const environmentIds = authentication.environmentPermissions.map(
|
||||||
return Response.json({ data: webhooks });
|
(permission) => permission.environmentId
|
||||||
|
);
|
||||||
|
const webhooks = await getWebhooks(environmentIds);
|
||||||
|
return responses.successResponse(webhooks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
return responses.badRequestResponse(error.message);
|
return responses.internalServerErrorResponse(error.message);
|
||||||
}
|
}
|
||||||
return responses.internalServerErrorResponse(error.message);
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = async (request: Request) => {
|
export const POST = async (request: Request) => {
|
||||||
const headersList = await headers();
|
const authentication = await authenticateRequest(request);
|
||||||
const apiKey = headersList.get("x-api-key");
|
if (!authentication) {
|
||||||
if (!apiKey) {
|
|
||||||
return responses.notAuthenticatedResponse();
|
|
||||||
}
|
|
||||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
|
||||||
if (!environmentId) {
|
|
||||||
return responses.notAuthenticatedResponse();
|
return responses.notAuthenticatedResponse();
|
||||||
}
|
}
|
||||||
const webhookInput = await request.json();
|
const webhookInput = await request.json();
|
||||||
@@ -50,9 +41,19 @@ export const POST = async (request: Request) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const environmentId = inputValidation.data.environmentId;
|
||||||
|
|
||||||
|
if (!environmentId) {
|
||||||
|
return responses.badRequestResponse("Environment ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||||
|
return responses.unauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
// add webhook to database
|
// add webhook to database
|
||||||
try {
|
try {
|
||||||
const webhook = await createWebhook(environmentId, inputValidation.data);
|
const webhook = await createWebhook(inputValidation.data);
|
||||||
return responses.successResponse(webhook);
|
return responses.successResponse(webhook);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvalidInputError) {
|
if (error instanceof InvalidInputError) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({
|
|||||||
surveyIds: true,
|
surveyIds: true,
|
||||||
triggers: true,
|
triggers: true,
|
||||||
url: true,
|
url: true,
|
||||||
|
environmentId: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TWebhookInput = z.infer<typeof ZWebhookInput>;
|
export type TWebhookInput = z.infer<typeof ZWebhookInput>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
OPTIONS,
|
OPTIONS,
|
||||||
PUT,
|
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 };
|
export { OPTIONS, PUT };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
GET,
|
GET,
|
||||||
OPTIONS,
|
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 };
|
export { GET, OPTIONS };
|
||||||
|
|||||||
@@ -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 };
|
export { POST, OPTIONS };
|
||||||
|
|||||||
3
apps/web/app/api/v2/management/contacts/bulk/route.ts
Normal file
3
apps/web/app/api/v2/management/contacts/bulk/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { PUT } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/route";
|
||||||
|
|
||||||
|
export { PUT };
|
||||||
3
apps/web/app/api/v2/me/route.ts
Normal file
3
apps/web/app/api/v2/me/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET } from "@/modules/api/v2/me/route";
|
||||||
|
|
||||||
|
export { GET };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
|
||||||
|
|
||||||
|
export { GET, POST, PUT, DELETE };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
|
||||||
|
|
||||||
|
export { GET, PUT, DELETE };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
|
||||||
|
|
||||||
|
export { GET, POST };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
|
||||||
|
|
||||||
|
export { GET, POST, PATCH };
|
||||||
3
apps/web/app/api/v2/roles/route.ts
Normal file
3
apps/web/app/api/v2/roles/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET } from "@/modules/api/v2/roles/route";
|
||||||
|
|
||||||
|
export { GET };
|
||||||
@@ -29,6 +29,7 @@ vi.mock("@formbricks/lib/constants", () => ({
|
|||||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/tolgee/language", () => ({
|
vi.mock("@/tolgee/language", () => ({
|
||||||
@@ -39,10 +40,6 @@ vi.mock("@/tolgee/server", () => ({
|
|||||||
getTolgee: vi.fn(),
|
getTolgee: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@vercel/speed-insights/next", () => ({
|
|
||||||
SpeedInsights: () => <div data-testid="speed-insights">SpeedInsights</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||||
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
|
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
|
||||||
<div data-testid="ph-provider">
|
<div data-testid="ph-provider">
|
||||||
@@ -69,6 +66,15 @@ vi.mock("@/tolgee/client", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/sentry/SentryProvider", () => ({
|
||||||
|
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
|
||||||
|
<div data-testid="sentry-provider">
|
||||||
|
SentryProvider: {sentryDsn}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("RootLayout", () => {
|
describe("RootLayout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -91,12 +97,8 @@ describe("RootLayout", () => {
|
|||||||
const element = await RootLayout({ children });
|
const element = await RootLayout({ children });
|
||||||
render(element);
|
render(element);
|
||||||
|
|
||||||
// log env vercel
|
|
||||||
console.log("vercel", process.env.VERCEL);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { PHProvider } from "@/modules/ui/components/post-hog-client";
|
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||||
import { getLocale } from "@/tolgee/language";
|
import { getLocale } from "@/tolgee/language";
|
||||||
import { getTolgee } from "@/tolgee/server";
|
import { getTolgee } from "@/tolgee/server";
|
||||||
import { TolgeeStaticData } from "@tolgee/react";
|
import { TolgeeStaticData } from "@tolgee/react";
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
|
import { SENTRY_DSN } from "@formbricks/lib/constants";
|
||||||
import "../modules/ui/globals.css";
|
import "../modules/ui/globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -26,12 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
return (
|
return (
|
||||||
<html lang={locale} translate="no">
|
<html lang={locale} translate="no">
|
||||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
<SentryProvider sentryDsn={SENTRY_DSN}>
|
||||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
|
||||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||||
{children}
|
{children}
|
||||||
</TolgeeNextProvider>
|
</TolgeeNextProvider>
|
||||||
</PHProvider>
|
</SentryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
export const fetchFile = async (
|
|
||||||
data: { json: any; fields?: string[]; fileName?: string },
|
|
||||||
filetype: string
|
|
||||||
) => {
|
|
||||||
const endpoint = filetype === "csv" ? "csv-conversion" : "excel-conversion";
|
|
||||||
|
|
||||||
const response = await fetch(`/api/${endpoint}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to convert to file");
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
101
apps/web/app/sentry/SentryProvider.test.tsx
Normal file
101
apps/web/app/sentry/SentryProvider.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SentryProvider } from "./SentryProvider";
|
||||||
|
|
||||||
|
vi.mock("@sentry/nextjs", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@sentry/nextjs")>("@sentry/nextjs");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
replayIntegration: (options: any) => {
|
||||||
|
return {
|
||||||
|
name: "Replay",
|
||||||
|
id: "Replay",
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SentryProvider", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls Sentry.init when sentryDsn is provided", () => {
|
||||||
|
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||||
|
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SentryProvider sentryDsn={sentryDsn}>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The useEffect runs after mount, so Sentry.init should have been called.
|
||||||
|
expect(initSpy).toHaveBeenCalled();
|
||||||
|
expect(initSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
debug: false,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
integrations: expect.any(Array),
|
||||||
|
beforeSend: expect.any(Function),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call Sentry.init when sentryDsn is not provided", () => {
|
||||||
|
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SentryProvider>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders children", () => {
|
||||||
|
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||||
|
render(
|
||||||
|
<SentryProvider sentryDsn={sentryDsn}>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes beforeSend correctly", () => {
|
||||||
|
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||||
|
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SentryProvider sentryDsn={sentryDsn}>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = initSpy.mock.calls[0][0];
|
||||||
|
expect(config).toHaveProperty("beforeSend");
|
||||||
|
const beforeSend = config.beforeSend;
|
||||||
|
|
||||||
|
if (!beforeSend) {
|
||||||
|
throw new Error("beforeSend is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
|
||||||
|
|
||||||
|
const hintWithNextNotFound = { originalException: { digest: "NEXT_NOT_FOUND" } };
|
||||||
|
expect(beforeSend(dummyEvent, hintWithNextNotFound)).toBeNull();
|
||||||
|
|
||||||
|
const hintWithOtherError = { originalException: { digest: "OTHER_ERROR" } };
|
||||||
|
expect(beforeSend(dummyEvent, hintWithOtherError)).toEqual(dummyEvent);
|
||||||
|
|
||||||
|
const hintWithoutError = { originalException: undefined };
|
||||||
|
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
apps/web/app/sentry/SentryProvider.tsx
Normal file
53
apps/web/app/sentry/SentryProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface SentryProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sentryDsn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (sentryDsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
|
||||||
|
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||||
|
// in development and sample at a lower rate in production
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
|
||||||
|
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||||
|
integrations: [
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
// Additional Replay configuration goes in here, for example:
|
||||||
|
maskAllText: true,
|
||||||
|
blockAllMedia: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
beforeSend(event, hint) {
|
||||||
|
const error = hint.originalException as Error;
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
if (error && error.digest === "NEXT_NOT_FOUND") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ export const getFile = async (
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": metaData.contentType,
|
"Content-Type": metaData.contentType,
|
||||||
"Content-Disposition": "attachment",
|
"Content-Disposition": "attachment",
|
||||||
"Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
|
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||||
Vary: "Accept-Encoding",
|
Vary: "Accept-Encoding",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -35,10 +35,7 @@ export const getFile = async (
|
|||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: signedUrl,
|
Location: signedUrl,
|
||||||
"Cache-Control":
|
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||||
accessType === "public"
|
|
||||||
? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
|
|
||||||
: `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { env } from "@formbricks/lib/env";
|
import { PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants";
|
||||||
|
|
||||||
// instrumentation.ts
|
// instrumentation.ts
|
||||||
export const register = async () => {
|
export const register = async () => {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs" && env.PROMETHEUS_ENABLED) {
|
if (process.env.NEXT_RUNTIME === "nodejs" && PROMETHEUS_ENABLED) {
|
||||||
await import("./instrumentation-node");
|
await import("./instrumentation-node");
|
||||||
}
|
}
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs" && SENTRY_DSN) {
|
||||||
|
await import("./sentry.server.config");
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge" && SENTRY_DSN) {
|
||||||
|
await import("./sentry.edge.config");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
18
apps/web/lib/cache/api-key.ts
vendored
18
apps/web/lib/cache/api-key.ts
vendored
@@ -2,8 +2,8 @@ import { revalidateTag } from "next/cache";
|
|||||||
|
|
||||||
interface RevalidateProps {
|
interface RevalidateProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
environmentId?: string;
|
|
||||||
hashedKey?: string;
|
hashedKey?: string;
|
||||||
|
organizationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiKeyCache = {
|
export const apiKeyCache = {
|
||||||
@@ -11,24 +11,24 @@ export const apiKeyCache = {
|
|||||||
byId(id: string) {
|
byId(id: string) {
|
||||||
return `apiKeys-${id}`;
|
return `apiKeys-${id}`;
|
||||||
},
|
},
|
||||||
byEnvironmentId(environmentId: string) {
|
|
||||||
return `environments-${environmentId}-apiKeys`;
|
|
||||||
},
|
|
||||||
byHashedKey(hashedKey: string) {
|
byHashedKey(hashedKey: string) {
|
||||||
return `apiKeys-${hashedKey}-apiKey`;
|
return `apiKeys-${hashedKey}-apiKey`;
|
||||||
},
|
},
|
||||||
|
byOrganizationId(organizationId: string) {
|
||||||
|
return `organizations-${organizationId}-apiKeys`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
revalidate({ id, environmentId, hashedKey }: RevalidateProps): void {
|
revalidate({ id, hashedKey, organizationId }: RevalidateProps): void {
|
||||||
if (id) {
|
if (id) {
|
||||||
revalidateTag(this.tag.byId(id));
|
revalidateTag(this.tag.byId(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (environmentId) {
|
|
||||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hashedKey) {
|
if (hashedKey) {
|
||||||
revalidateTag(this.tag.byHashedKey(hashedKey));
|
revalidateTag(this.tag.byHashedKey(hashedKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
revalidateTag(this.tag.byOrganizationId(organizationId));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => {
|
|||||||
throw new ResourceNotFoundError("apiKey", apiKeyId);
|
throw new ResourceNotFoundError("apiKey", apiKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId);
|
return apiKeyFromServer.organizationId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrganizationIdFromInviteId = async (inviteId: string) => {
|
export const getOrganizationIdFromInviteId = async (inviteId: string) => {
|
||||||
@@ -240,15 +240,6 @@ export const getProjectIdFromSegmentId = async (segmentId: string) => {
|
|||||||
return await getProjectIdFromEnvironmentId(segment.environmentId);
|
return await getProjectIdFromEnvironmentId(segment.environmentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectIdFromApiKeyId = async (apiKeyId: string) => {
|
|
||||||
const apiKey = await getApiKey(apiKeyId);
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new ResourceNotFoundError("apiKey", apiKeyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await getProjectIdFromEnvironmentId(apiKey.environmentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getProjectIdFromActionClassId = async (actionClassId: string) => {
|
export const getProjectIdFromActionClassId = async (actionClassId: string) => {
|
||||||
const actionClass = await getActionClass(actionClassId);
|
const actionClass = await getActionClass(actionClassId);
|
||||||
if (!actionClass) {
|
if (!actionClass) {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const getActionClass = reactCache(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getApiKey = reactCache(
|
export const getApiKey = reactCache(
|
||||||
async (apiKeyId: string): Promise<{ environmentId: string } | null> =>
|
async (apiKeyId: string): Promise<{ organizationId: string } | null> =>
|
||||||
cache(
|
cache(
|
||||||
async () => {
|
async () => {
|
||||||
validateInputs([apiKeyId, ZString]);
|
validateInputs([apiKeyId, ZString]);
|
||||||
@@ -66,7 +66,7 @@ export const getApiKey = reactCache(
|
|||||||
id: apiKeyId,
|
id: apiKeyId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
environmentId: true,
|
organizationId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,27 @@ import { ipAddress } from "@vercel/functions";
|
|||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
|
import {
|
||||||
|
E2E_TESTING,
|
||||||
|
IS_PRODUCTION,
|
||||||
|
RATE_LIMITING_DISABLED,
|
||||||
|
SURVEY_URL,
|
||||||
|
WEBAPP_URL,
|
||||||
|
} from "@formbricks/lib/constants";
|
||||||
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
|
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
const enforceHttps = (request: NextRequest): Response | null => {
|
const enforceHttps = (request: NextRequest): Response | null => {
|
||||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||||
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
||||||
const apiError: ApiErrorResponseV2 = {
|
const apiError: ApiErrorResponseV2 = {
|
||||||
type: "forbidden",
|
type: "forbidden",
|
||||||
details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
|
details: [
|
||||||
|
{
|
||||||
|
field: "",
|
||||||
|
issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
logApiError(request, apiError);
|
logApiError(request, apiError);
|
||||||
return NextResponse.json(apiError, { status: 403 });
|
return NextResponse.json(apiError, { status: 403 });
|
||||||
@@ -78,7 +90,34 @@ const applyRateLimiting = (request: NextRequest, ip: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSurveyDomain = (request: NextRequest): Response | null => {
|
||||||
|
try {
|
||||||
|
if (!SURVEY_URL) return null;
|
||||||
|
|
||||||
|
const host = request.headers.get("host") || "";
|
||||||
|
const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : "";
|
||||||
|
if (host !== surveyDomain) return null;
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, "Error handling survey domain");
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSurveyRoute = (request: NextRequest) => {
|
||||||
|
return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/");
|
||||||
|
};
|
||||||
|
|
||||||
export const middleware = async (originalRequest: NextRequest) => {
|
export const middleware = async (originalRequest: NextRequest) => {
|
||||||
|
if (isSurveyRoute(originalRequest)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle survey domain routing.
|
||||||
|
const surveyResponse = handleSurveyDomain(originalRequest);
|
||||||
|
if (surveyResponse) return surveyResponse;
|
||||||
|
|
||||||
// Create a new Request object to override headers and add a unique request ID header
|
// Create a new Request object to override headers and add a unique request ID header
|
||||||
const request = new NextRequest(originalRequest, {
|
const request = new NextRequest(originalRequest, {
|
||||||
headers: new Headers(originalRequest.headers),
|
headers: new Headers(originalRequest.headers),
|
||||||
@@ -88,6 +127,7 @@ export const middleware = async (originalRequest: NextRequest) => {
|
|||||||
request.headers.set("x-start-time", Date.now().toString());
|
request.headers.set("x-start-time", Date.now().toString());
|
||||||
|
|
||||||
// Create a new NextResponse object to forward the new request with headers
|
// Create a new NextResponse object to forward the new request with headers
|
||||||
|
|
||||||
const nextResponseWithCustomHeader = NextResponse.next({
|
const nextResponseWithCustomHeader = NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
@@ -132,20 +172,6 @@ export const middleware = async (originalRequest: NextRequest) => {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/api/auth/callback/credentials",
|
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware
|
||||||
"/api/(.*)/client/:path*",
|
|
||||||
"/api/v1/js/actions",
|
|
||||||
"/api/v1/client/storage",
|
|
||||||
"/share/(.*)/:path",
|
|
||||||
"/environments/:path*",
|
|
||||||
"/setup/organization/:path*",
|
|
||||||
"/api/auth/signout",
|
|
||||||
"/auth/login",
|
|
||||||
"/auth/signup",
|
|
||||||
"/api/packages/:path*",
|
|
||||||
"/auth/verification-requested",
|
|
||||||
"/auth/forgot-password",
|
|
||||||
"/api/v1/management/:path*",
|
|
||||||
"/api/v2/management/:path*",
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
|
|||||||
|
|
||||||
interface ShareSurveyLinkProps {
|
interface ShareSurveyLinkProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
webAppUrl: string;
|
surveyDomain: string;
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
setSurveyUrl: (url: string) => void;
|
setSurveyUrl: (url: string) => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
@@ -23,8 +23,8 @@ interface ShareSurveyLinkProps {
|
|||||||
|
|
||||||
export const ShareSurveyLink = ({
|
export const ShareSurveyLink = ({
|
||||||
survey,
|
survey,
|
||||||
webAppUrl,
|
|
||||||
surveyUrl,
|
surveyUrl,
|
||||||
|
surveyDomain,
|
||||||
setSurveyUrl,
|
setSurveyUrl,
|
||||||
locale,
|
locale,
|
||||||
}: ShareSurveyLinkProps) => {
|
}: ShareSurveyLinkProps) => {
|
||||||
@@ -32,7 +32,7 @@ export const ShareSurveyLink = ({
|
|||||||
const [language, setLanguage] = useState("default");
|
const [language, setLanguage] = useState("default");
|
||||||
|
|
||||||
const getUrl = useCallback(async () => {
|
const getUrl = useCallback(async () => {
|
||||||
let url = `${webAppUrl}/s/${survey.id}`;
|
let url = `${surveyDomain}/s/${survey.id}`;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
|
|
||||||
if (survey.singleUse?.enabled) {
|
if (survey.singleUse?.enabled) {
|
||||||
@@ -58,7 +58,9 @@ export const ShareSurveyLink = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSurveyUrl(url);
|
setSurveyUrl(url);
|
||||||
}, [survey, webAppUrl, language]);
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [survey, surveyDomain, language]);
|
||||||
|
|
||||||
const generateNewSingleUseLink = () => {
|
const generateNewSingleUseLink = () => {
|
||||||
getUrl();
|
getUrl();
|
||||||
|
|||||||
102
apps/web/modules/api/v2/auth/api-wrapper.ts
Normal file
102
apps/web/modules/api/v2/auth/api-wrapper.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||||
|
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||||
|
import { ZodRawShape, z } from "zod";
|
||||||
|
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||||
|
import { authenticateRequest } from "./authenticate-request";
|
||||||
|
|
||||||
|
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
||||||
|
authentication,
|
||||||
|
parsedInput,
|
||||||
|
request,
|
||||||
|
}: {
|
||||||
|
authentication: TAuthenticationApiKey;
|
||||||
|
parsedInput: TInput;
|
||||||
|
request: Request;
|
||||||
|
}) => Promise<Response>;
|
||||||
|
|
||||||
|
export type ExtendedSchemas = {
|
||||||
|
body?: z.ZodObject<ZodRawShape>;
|
||||||
|
query?: z.ZodObject<ZodRawShape>;
|
||||||
|
params?: z.ZodObject<ZodRawShape>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define a type that returns separate keys for each input type.
|
||||||
|
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
|
||||||
|
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
|
||||||
|
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
|
||||||
|
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||||
|
request,
|
||||||
|
schemas,
|
||||||
|
externalParams,
|
||||||
|
rateLimit = true,
|
||||||
|
handler,
|
||||||
|
}: {
|
||||||
|
request: Request;
|
||||||
|
schemas?: S;
|
||||||
|
externalParams?: Promise<Record<string, any>>;
|
||||||
|
rateLimit?: boolean;
|
||||||
|
handler: HandlerFn<ParsedSchemas<S>>;
|
||||||
|
}): Promise<Response> => {
|
||||||
|
const authentication = await authenticateRequest(request);
|
||||||
|
if (!authentication.ok) {
|
||||||
|
return handleApiError(request, authentication.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||||
|
|
||||||
|
if (schemas?.body) {
|
||||||
|
const bodyData = await request.json();
|
||||||
|
const bodyResult = schemas.body.safeParse(bodyData);
|
||||||
|
|
||||||
|
if (!bodyResult.success) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "unprocessable_entity",
|
||||||
|
details: formatZodError(bodyResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parsedInput.body = bodyResult.data as ParsedSchemas<S>["body"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas?.query) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const queryObject = Object.fromEntries(url.searchParams.entries());
|
||||||
|
const queryResult = schemas.query.safeParse(queryObject);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "unprocessable_entity",
|
||||||
|
details: formatZodError(queryResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parsedInput.query = queryResult.data as ParsedSchemas<S>["query"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas?.params) {
|
||||||
|
const paramsObject = (await externalParams) || {};
|
||||||
|
const paramsResult = schemas.params.safeParse(paramsObject);
|
||||||
|
if (!paramsResult.success) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "unprocessable_entity",
|
||||||
|
details: formatZodError(paramsResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parsedInput.params = paramsResult.data as ParsedSchemas<S>["params"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimit) {
|
||||||
|
const rateLimitResponse = await checkRateLimitAndThrowError({
|
||||||
|
identifier: authentication.data.hashedApiKey,
|
||||||
|
});
|
||||||
|
if (!rateLimitResponse.ok) {
|
||||||
|
return handleApiError(request, rateLimitResponse.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler({
|
||||||
|
authentication: authentication.data,
|
||||||
|
parsedInput,
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
};
|
||||||
34
apps/web/modules/api/v2/auth/authenticate-request.ts
Normal file
34
apps/web/modules/api/v2/auth/authenticate-request.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||||
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
|
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||||
|
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||||
|
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||||
|
|
||||||
|
export const authenticateRequest = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||||
|
const apiKey = request.headers.get("x-api-key");
|
||||||
|
if (!apiKey) return err({ type: "unauthorized" });
|
||||||
|
|
||||||
|
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||||
|
|
||||||
|
if (!apiKeyData) return err({ type: "unauthorized" });
|
||||||
|
|
||||||
|
const hashedApiKey = hashApiKey(apiKey);
|
||||||
|
|
||||||
|
const authentication: TAuthenticationApiKey = {
|
||||||
|
type: "apiKey",
|
||||||
|
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||||
|
environmentId: env.environmentId,
|
||||||
|
environmentType: env.environment.type,
|
||||||
|
permission: env.permission,
|
||||||
|
projectId: env.environment.projectId,
|
||||||
|
projectName: env.environment.project.name,
|
||||||
|
})),
|
||||||
|
hashedApiKey,
|
||||||
|
apiKeyId: apiKeyData.id,
|
||||||
|
organizationId: apiKeyData.organizationId,
|
||||||
|
organizationAccess: apiKeyData.organizationAccess,
|
||||||
|
};
|
||||||
|
return ok(authentication);
|
||||||
|
};
|
||||||
42
apps/web/modules/api/v2/auth/authenticated-api-client.ts
Normal file
42
apps/web/modules/api/v2/auth/authenticated-api-client.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||||
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
|
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
|
||||||
|
|
||||||
|
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
||||||
|
request,
|
||||||
|
schemas,
|
||||||
|
externalParams,
|
||||||
|
rateLimit = true,
|
||||||
|
handler,
|
||||||
|
}: {
|
||||||
|
request: Request;
|
||||||
|
schemas?: S;
|
||||||
|
externalParams?: Promise<Record<string, any>>;
|
||||||
|
rateLimit?: boolean;
|
||||||
|
handler: HandlerFn<ParsedSchemas<S>>;
|
||||||
|
}): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const response = await apiWrapper({
|
||||||
|
request,
|
||||||
|
schemas,
|
||||||
|
externalParams,
|
||||||
|
rateLimit,
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
logApiRequest(request, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
if ("type" in err) {
|
||||||
|
return handleApiError(request, err as ApiErrorResponseV2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "internal_server_error",
|
||||||
|
details: [{ field: "error", issue: "An error occurred while processing your request." }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||||
|
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||||
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
|
|
||||||
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
|
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -19,6 +19,11 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
|
|||||||
handleApiError: vi.fn(),
|
handleApiError: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||||
|
formatZodError: vi.fn(),
|
||||||
|
handleApiError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("apiWrapper", () => {
|
describe("apiWrapper", () => {
|
||||||
it("should handle request and return response", async () => {
|
it("should handle request and return response", async () => {
|
||||||
const request = new Request("http://localhost", {
|
const request = new Request("http://localhost", {
|
||||||
114
apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts
Normal file
114
apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { authenticateRequest } from "../authenticate-request";
|
||||||
|
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
apiKey: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||||
|
hashApiKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("authenticateRequest", () => {
|
||||||
|
it("should return authentication data if apiKey is valid", async () => {
|
||||||
|
const request = new Request("http://localhost", {
|
||||||
|
headers: { "x-api-key": "valid-api-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockApiKeyData = {
|
||||||
|
id: "api-key-id",
|
||||||
|
organizationId: "org-id",
|
||||||
|
createdAt: new Date(),
|
||||||
|
createdBy: "user-id",
|
||||||
|
lastUsedAt: null,
|
||||||
|
label: "Test API Key",
|
||||||
|
hashedKey: "hashed-api-key",
|
||||||
|
apiKeyEnvironments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-id-1",
|
||||||
|
permission: "manage",
|
||||||
|
environment: {
|
||||||
|
id: "env-id-1",
|
||||||
|
projectId: "project-id-1",
|
||||||
|
type: "development",
|
||||||
|
project: { name: "Project 1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-id-2",
|
||||||
|
permission: "read",
|
||||||
|
environment: {
|
||||||
|
id: "env-id-2",
|
||||||
|
projectId: "project-id-2",
|
||||||
|
type: "production",
|
||||||
|
project: { name: "Project 2" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||||
|
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||||
|
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||||
|
|
||||||
|
const result = await authenticateRequest(request);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
type: "apiKey",
|
||||||
|
environmentPermissions: [
|
||||||
|
{
|
||||||
|
environmentId: "env-id-1",
|
||||||
|
permission: "manage",
|
||||||
|
environmentType: "development",
|
||||||
|
projectId: "project-id-1",
|
||||||
|
projectName: "Project 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-id-2",
|
||||||
|
permission: "read",
|
||||||
|
environmentType: "production",
|
||||||
|
projectId: "project-id-2",
|
||||||
|
projectName: "Project 2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashedApiKey: "hashed-api-key",
|
||||||
|
apiKeyId: "api-key-id",
|
||||||
|
organizationId: "org-id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return unauthorized error if apiKey is not found", async () => {
|
||||||
|
const request = new Request("http://localhost", {
|
||||||
|
headers: { "x-api-key": "invalid-api-key" },
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await authenticateRequest(request);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toEqual({ type: "unauthorized" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return unauthorized error if apiKey is missing", async () => {
|
||||||
|
const request = new Request("http://localhost");
|
||||||
|
|
||||||
|
const result = await authenticateRequest(request);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toEqual({ type: "unauthorized" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,9 +122,11 @@ const notFoundResponse = ({
|
|||||||
const conflictResponse = ({
|
const conflictResponse = ({
|
||||||
cors = false,
|
cors = false,
|
||||||
cache = "private, no-store",
|
cache = "private, no-store",
|
||||||
|
details = [],
|
||||||
}: {
|
}: {
|
||||||
cors?: boolean;
|
cors?: boolean;
|
||||||
cache?: string;
|
cache?: string;
|
||||||
|
details?: ApiErrorDetails;
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const headers = {
|
const headers = {
|
||||||
...(cors && corsHeaders),
|
...(cors && corsHeaders),
|
||||||
@@ -136,6 +138,7 @@ const conflictResponse = ({
|
|||||||
error: {
|
error: {
|
||||||
code: 409,
|
code: 409,
|
||||||
message: "Conflict",
|
message: "Conflict",
|
||||||
|
details,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -232,7 +235,7 @@ const internalServerErrorResponse = ({
|
|||||||
const successResponse = ({
|
const successResponse = ({
|
||||||
data,
|
data,
|
||||||
meta,
|
meta,
|
||||||
cors = false,
|
cors = true,
|
||||||
cache = "private, no-store",
|
cache = "private, no-store",
|
||||||
}: {
|
}: {
|
||||||
data: Object;
|
data: Object;
|
||||||
@@ -257,6 +260,34 @@ const successResponse = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const multiStatusResponse = ({
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
cors = false,
|
||||||
|
cache = "private, no-store",
|
||||||
|
}: {
|
||||||
|
data: Object;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
cors?: boolean;
|
||||||
|
cache?: string;
|
||||||
|
}) => {
|
||||||
|
const headers = {
|
||||||
|
...(cors && corsHeaders),
|
||||||
|
"Cache-Control": cache,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
} as ApiSuccessResponse,
|
||||||
|
{
|
||||||
|
status: 207,
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const responses = {
|
export const responses = {
|
||||||
badRequestResponse,
|
badRequestResponse,
|
||||||
unauthorizedResponse,
|
unauthorizedResponse,
|
||||||
@@ -267,4 +298,5 @@ export const responses = {
|
|||||||
tooManyRequestsResponse,
|
tooManyRequestsResponse,
|
||||||
internalServerErrorResponse,
|
internalServerErrorResponse,
|
||||||
successResponse,
|
successResponse,
|
||||||
|
multiStatusResponse,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,13 +85,15 @@ describe("API Responses", () => {
|
|||||||
|
|
||||||
describe("conflictResponse", () => {
|
describe("conflictResponse", () => {
|
||||||
test("return a 409 response", async () => {
|
test("return a 409 response", async () => {
|
||||||
const res = responses.conflictResponse();
|
const details = [{ field: "resource", issue: "already exists" }];
|
||||||
|
const res = responses.conflictResponse({ details });
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
error: {
|
error: {
|
||||||
code: 409,
|
code: 409,
|
||||||
message: "Conflict",
|
message: "Conflict",
|
||||||
|
details,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { responses } from "@/modules/api/v2/lib/response";
|
import { responses } from "@/modules/api/v2/lib/response";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { ZodError } from "zod";
|
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
|
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
|
||||||
@@ -16,7 +16,7 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
|
|||||||
case "not_found":
|
case "not_found":
|
||||||
return responses.notFoundResponse({ details: err.details });
|
return responses.notFoundResponse({ details: err.details });
|
||||||
case "conflict":
|
case "conflict":
|
||||||
return responses.conflictResponse();
|
return responses.conflictResponse({ details: err.details });
|
||||||
case "unprocessable_entity":
|
case "unprocessable_entity":
|
||||||
return responses.unprocessableEntityResponse({ details: err.details });
|
return responses.unprocessableEntityResponse({ details: err.details });
|
||||||
case "too_many_requests":
|
case "too_many_requests":
|
||||||
@@ -34,11 +34,16 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatZodError = (error: ZodError) => {
|
export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] }) => {
|
||||||
return error.issues.map((issue) => ({
|
return error.issues.map((issue) => {
|
||||||
field: issue.path.join("."),
|
const issueParams = issue.code === "custom" ? issue.params : undefined;
|
||||||
issue: issue.message,
|
|
||||||
}));
|
return {
|
||||||
|
field: issue.path.join("."),
|
||||||
|
issue: issue.message ?? "An error occurred while processing your request. Please try again later.",
|
||||||
|
...(issueParams && { meta: issueParams }),
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logApiRequest = (request: Request, responseStatus: number): void => {
|
export const logApiRequest = (request: Request, responseStatus: number): void => {
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
|
||||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
|
||||||
import { ZodRawShape, z } from "zod";
|
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
|
||||||
import { err } from "@formbricks/types/error-handlers";
|
|
||||||
import { authenticateRequest } from "./authenticate-request";
|
|
||||||
|
|
||||||
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
|
||||||
authentication,
|
|
||||||
parsedInput,
|
|
||||||
request,
|
|
||||||
}: {
|
|
||||||
authentication: TAuthenticationApiKey;
|
|
||||||
parsedInput: TInput;
|
|
||||||
request: Request;
|
|
||||||
}) => Promise<Response>;
|
|
||||||
|
|
||||||
export type ExtendedSchemas = {
|
|
||||||
body?: z.ZodObject<ZodRawShape>;
|
|
||||||
query?: z.ZodObject<ZodRawShape>;
|
|
||||||
params?: z.ZodObject<ZodRawShape>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define a type that returns separate keys for each input type.
|
|
||||||
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
|
|
||||||
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
|
|
||||||
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
|
|
||||||
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const apiWrapper = async <S extends ExtendedSchemas>({
|
|
||||||
request,
|
|
||||||
schemas,
|
|
||||||
externalParams,
|
|
||||||
rateLimit = true,
|
|
||||||
handler,
|
|
||||||
}: {
|
|
||||||
request: Request;
|
|
||||||
schemas?: S;
|
|
||||||
externalParams?: Promise<Record<string, any>>;
|
|
||||||
rateLimit?: boolean;
|
|
||||||
handler: HandlerFn<ParsedSchemas<S>>;
|
|
||||||
}): Promise<Response> => {
|
|
||||||
try {
|
|
||||||
const authentication = await authenticateRequest(request);
|
|
||||||
if (!authentication.ok) return handleApiError(request, authentication.error);
|
|
||||||
|
|
||||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
|
||||||
|
|
||||||
if (schemas?.body) {
|
|
||||||
const bodyData = await request.json();
|
|
||||||
const bodyResult = schemas.body.safeParse(bodyData);
|
|
||||||
|
|
||||||
if (!bodyResult.success) {
|
|
||||||
throw err({
|
|
||||||
type: "bad_request",
|
|
||||||
details: formatZodError(bodyResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
parsedInput.body = bodyResult.data as ParsedSchemas<S>["body"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schemas?.query) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const queryObject = Object.fromEntries(url.searchParams.entries());
|
|
||||||
const queryResult = schemas.query.safeParse(queryObject);
|
|
||||||
if (!queryResult.success) {
|
|
||||||
throw err({
|
|
||||||
type: "unprocessable_entity",
|
|
||||||
details: formatZodError(queryResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
parsedInput.query = queryResult.data as ParsedSchemas<S>["query"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schemas?.params) {
|
|
||||||
const paramsObject = (await externalParams) || {};
|
|
||||||
const paramsResult = schemas.params.safeParse(paramsObject);
|
|
||||||
if (!paramsResult.success) {
|
|
||||||
throw err({
|
|
||||||
type: "unprocessable_entity",
|
|
||||||
details: formatZodError(paramsResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
parsedInput.params = paramsResult.data as ParsedSchemas<S>["params"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rateLimit) {
|
|
||||||
const rateLimitResponse = await checkRateLimitAndThrowError({
|
|
||||||
identifier: authentication.data.hashedApiKey,
|
|
||||||
});
|
|
||||||
if (!rateLimitResponse.ok) {
|
|
||||||
throw rateLimitResponse.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler({
|
|
||||||
authentication: authentication.data,
|
|
||||||
parsedInput,
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return handleApiError(request, err.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
|
||||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
|
||||||
|
|
||||||
export const authenticateRequest = async (
|
|
||||||
request: Request
|
|
||||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
|
||||||
const apiKey = request.headers.get("x-api-key");
|
|
||||||
|
|
||||||
if (apiKey) {
|
|
||||||
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
|
||||||
if (!environmentIdResult.ok) {
|
|
||||||
return err(environmentIdResult.error);
|
|
||||||
}
|
|
||||||
const environmentId = environmentIdResult.data;
|
|
||||||
const hashedApiKey = hashApiKey(apiKey);
|
|
||||||
if (environmentId) {
|
|
||||||
const authentication: TAuthenticationApiKey = {
|
|
||||||
type: "apiKey",
|
|
||||||
environmentId,
|
|
||||||
hashedApiKey,
|
|
||||||
};
|
|
||||||
return ok(authentication);
|
|
||||||
}
|
|
||||||
return err({
|
|
||||||
type: "forbidden",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return err({
|
|
||||||
type: "unauthorized",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { logApiRequest } from "@/modules/api/v2/lib/utils";
|
|
||||||
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
|
|
||||||
|
|
||||||
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
|
||||||
request,
|
|
||||||
schemas,
|
|
||||||
externalParams,
|
|
||||||
rateLimit = true,
|
|
||||||
handler,
|
|
||||||
}: {
|
|
||||||
request: Request;
|
|
||||||
schemas?: S;
|
|
||||||
externalParams?: Promise<Record<string, any>>;
|
|
||||||
rateLimit?: boolean;
|
|
||||||
handler: HandlerFn<ParsedSchemas<S>>;
|
|
||||||
}): Promise<Response> => {
|
|
||||||
const response = await apiWrapper({
|
|
||||||
request,
|
|
||||||
schemas,
|
|
||||||
externalParams,
|
|
||||||
rateLimit,
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
logApiRequest(request, response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
|
||||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
|
||||||
|
|
||||||
export const checkAuthorization = ({
|
|
||||||
authentication,
|
|
||||||
environmentId,
|
|
||||||
}: {
|
|
||||||
authentication: TAuthenticationApiKey;
|
|
||||||
environmentId: string;
|
|
||||||
}): Result<void, ApiErrorResponseV2> => {
|
|
||||||
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
|
|
||||||
return err({
|
|
||||||
type: "unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return okVoid();
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
|
||||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { err, ok } from "@formbricks/types/error-handlers";
|
|
||||||
import { authenticateRequest } from "../authenticate-request";
|
|
||||||
|
|
||||||
vi.mock("@/modules/api/v2/management/lib/api-key", () => ({
|
|
||||||
getEnvironmentIdFromApiKey: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
|
||||||
hashApiKey: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("authenticateRequest", () => {
|
|
||||||
it("should return authentication data if apiKey is valid", async () => {
|
|
||||||
const request = new Request("http://localhost", {
|
|
||||||
headers: { "x-api-key": "valid-api-key" },
|
|
||||||
});
|
|
||||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id"));
|
|
||||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
|
||||||
|
|
||||||
const result = await authenticateRequest(request);
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
if (result.ok) {
|
|
||||||
expect(result.data).toEqual({
|
|
||||||
type: "apiKey",
|
|
||||||
environmentId: "env-id",
|
|
||||||
hashedApiKey: "hashed-api-key",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return forbidden error if environmentId is not found", async () => {
|
|
||||||
const request = new Request("http://localhost", {
|
|
||||||
headers: { "x-api-key": "invalid-api-key" },
|
|
||||||
});
|
|
||||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" }));
|
|
||||||
|
|
||||||
const result = await authenticateRequest(request);
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.error).toEqual({ type: "forbidden" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return forbidden error if environmentId is empty", async () => {
|
|
||||||
const request = new Request("http://localhost", {
|
|
||||||
headers: { "x-api-key": "invalid-api-key" },
|
|
||||||
});
|
|
||||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok(""));
|
|
||||||
|
|
||||||
const result = await authenticateRequest(request);
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.error).toEqual({ type: "forbidden" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return unauthorized error if apiKey is missing", async () => {
|
|
||||||
const request = new Request("http://localhost");
|
|
||||||
|
|
||||||
const result = await authenticateRequest(request);
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.error).toEqual({ type: "unauthorized" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
|
||||||
import { checkAuthorization } from "../check-authorization";
|
|
||||||
|
|
||||||
describe("checkAuthorization", () => {
|
|
||||||
it("should return ok if authentication is valid", () => {
|
|
||||||
const authentication: TAuthenticationApiKey = {
|
|
||||||
type: "apiKey",
|
|
||||||
environmentId: "env-id",
|
|
||||||
hashedApiKey: "hashed-api-key",
|
|
||||||
};
|
|
||||||
const result = checkAuthorization({ authentication, environmentId: "env-id" });
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return unauthorized error if environmentId does not match", () => {
|
|
||||||
const authentication: TAuthenticationApiKey = {
|
|
||||||
type: "apiKey",
|
|
||||||
environmentId: "env-id",
|
|
||||||
hashedApiKey: "hashed-api-key",
|
|
||||||
};
|
|
||||||
const result = checkAuthorization({ authentication, environmentId: "different-env-id" });
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.error).toEqual({ type: "unauthorized" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
|
||||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
|
||||||
import { cache as reactCache } from "react";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { cache } from "@formbricks/lib/cache";
|
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
|
||||||
|
|
||||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string) => {
|
|
||||||
const hashedKey = hashApiKey(apiKey);
|
|
||||||
return cache(
|
|
||||||
async (): Promise<Result<string, ApiErrorResponseV2>> => {
|
|
||||||
if (!apiKey) {
|
|
||||||
return err({
|
|
||||||
type: "bad_request",
|
|
||||||
details: [{ field: "apiKey", issue: "API key cannot be null or undefined." }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKeyData = await prisma.apiKey.findUnique({
|
|
||||||
where: {
|
|
||||||
hashedKey,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
environmentId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!apiKeyData) {
|
|
||||||
return err({ type: "not_found", details: [{ field: "apiKey", issue: "not found" }] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok(apiKeyData.environmentId);
|
|
||||||
} catch (error) {
|
|
||||||
return err({ type: "internal_server_error", details: [{ field: "apiKey", issue: error.message }] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[`management-api-getEnvironmentIdFromApiKey-${hashedKey}`],
|
|
||||||
{
|
|
||||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { apiKey, environmentId } from "./__mocks__/api-key.mock";
|
|
||||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
|
||||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
|
||||||
prisma: {
|
|
||||||
apiKey: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
|
||||||
hashApiKey: vi.fn((input: string) => `hashed-${input}`),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("getEnvironmentIdFromApiKey", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a bad_request error if apiKey is empty", async () => {
|
|
||||||
const result = await getEnvironmentIdFromApiKey("");
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.error.type).toBe("bad_request");
|
|
||||||
expect(result.error.details).toEqual([
|
|
||||||
{ field: "apiKey", issue: "API key cannot be null or undefined." },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a not_found error when no apiKey record is found in the database", async () => {
|
|
||||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
|
||||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
|
||||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { hashedKey: `hashed-${apiKey}` },
|
|
||||||
select: { environmentId: true },
|
|
||||||
});
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.error.type).toBe("not_found");
|
|
||||||
expect(result.error.details).toEqual([{ field: "apiKey", issue: "not found" }]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns ok with environmentId when a valid apiKey record is found", async () => {
|
|
||||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
|
||||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ environmentId });
|
|
||||||
|
|
||||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
|
||||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { hashedKey: `hashed-${apiKey}` },
|
|
||||||
select: { environmentId: true },
|
|
||||||
});
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
if (result.ok) {
|
|
||||||
expect(result.data).toBe(environmentId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns internal_server_error when an exception occurs during the database lookup", async () => {
|
|
||||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
|
||||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(new Error("Database failure"));
|
|
||||||
|
|
||||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
|
||||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { hashedKey: `hashed-${apiKey}` },
|
|
||||||
select: { environmentId: true },
|
|
||||||
});
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.error.type).toBe("internal_server_error");
|
|
||||||
expect(result.error.details).toEqual([{ field: "apiKey", issue: "Database failure" }]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,12 @@ export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
|||||||
return { limit, skip, sortBy, order, startDate, endDate };
|
return { limit, skip, sortBy, order, startDate, endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
|
type HasFindMany =
|
||||||
|
| Prisma.WebhookFindManyArgs
|
||||||
|
| Prisma.ResponseFindManyArgs
|
||||||
|
| Prisma.TeamFindManyArgs
|
||||||
|
| Prisma.ProjectTeamFindManyArgs
|
||||||
|
| Prisma.UserFindManyArgs;
|
||||||
|
|
||||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||||
@@ -11,7 +11,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
|||||||
description: "Gets a response from the database.",
|
description: "Gets a response from the database.",
|
||||||
requestParams: {
|
requestParams: {
|
||||||
path: z.object({
|
path: z.object({
|
||||||
id: responseIdSchema,
|
id: ZResponseIdSchema,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
tags: ["Management API > Responses"],
|
tags: ["Management API > Responses"],
|
||||||
@@ -34,7 +34,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
|||||||
tags: ["Management API > Responses"],
|
tags: ["Management API > Responses"],
|
||||||
requestParams: {
|
requestParams: {
|
||||||
path: z.object({
|
path: z.object({
|
||||||
id: responseIdSchema,
|
id: ZResponseIdSchema,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
@@ -56,7 +56,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
|||||||
tags: ["Management API > Responses"],
|
tags: ["Management API > Responses"],
|
||||||
requestParams: {
|
requestParams: {
|
||||||
path: z.object({
|
path: z.object({
|
||||||
id: responseIdSchema,
|
id: ZResponseIdSchema,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
requestBody: {
|
requestBody: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user