Compare commits

..

62 Commits

Author SHA1 Message Date
Piyush Gupta
e9650ef12f fix: sonarqube workflow 2025-03-10 15:13:04 +05:30
Dhruwang Jariwala
4424a8a21d feat: pt-PT translations (#4874) 2025-03-10 09:22:26 +00:00
Dhruwang Jariwala
eb030f9ed6 fix: Address/Contact question accessibility (#4884) 2025-03-10 09:21:44 +00:00
Piyush Gupta
333372d61c fix: removed link survey identification from share modal (#4898)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-03-10 06:45:28 +00:00
Piyush Gupta
48a92f3e55 feat: OIDC name fields added (#4872)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-09 08:42:00 +00:00
Piyush Gupta
ddc767e53e fix: logo on follow up email (#4837)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-03-08 06:40:40 +00:00
Matti Nannt
432425ea59 chore: add licenses to ios and android SDK (#4893) 2025-03-07 18:18:31 +01:00
Peter Pesti-Varga
6075fd3ef8 feat: Mobile SDK unit tests and Readme files (#4892) 2025-03-07 16:54:19 +01:00
Anshuman Pandey
f099a46f83 feat: surveys package integration with v2 apis (#4882)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-07 12:41:14 +00:00
Dhruwang Jariwala
fe54ef66c6 docs: activepieces docs for self hosters (#4891) 2025-03-07 12:27:56 +00:00
Piyush Gupta
4eb0e930f6 fix: improve multiple choice question summary calculation (#4829)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-03-07 12:24:12 +00:00
Dhruwang Jariwala
fae925aa25 fix: typo in email (#4880) 2025-03-07 10:07:23 +00:00
victorvhs017
764a3d2fde fix: added the test-key reference back (#4883) 2025-03-07 09:48:11 +00:00
Anshuman Pandey
b5a51f1304 fix: surveys pkg android file uploads (#4869)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-03-07 04:38:49 +00:00
Piyush Gupta
140aee749b feat: new management api crud endpoint for responses (#4716)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: victorvhs017 <115753265+victorvhs017@users.noreply.github.com>
2025-03-06 17:16:06 +00:00
Dhruwang Jariwala
4113dd1873 fix: signup vulnerability (#4859) 2025-03-06 12:08:40 +00:00
Dhruwang Jariwala
0e0d3780d3 fix: removed completed surveys from survey list in integrations (#4838)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-06 09:10:51 +00:00
Peter Pesti-Varga
38ff01aedc feat: Android & iOS SDK (#4871) 2025-03-06 09:43:49 +01:00
Dhruwang Jariwala
cdf687ad80 fix: delete webhook button visibility (#4862) 2025-03-06 07:40:00 +00:00
github-actions[bot]
a399fc7f80 chore: bump version to v3.3.1 (#4873)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-03-06 08:37:59 +01:00
Piyush Gupta
c54a48e70b docs: adds email followup docs (#4858)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-03-04 16:02:54 +00:00
Johannes
884b6f12ae docs: update API intro and key management docs (#4841) 2025-03-04 15:36:23 +00:00
Piyush Gupta
5cae0febc9 fix: variables initialization in logic editor preview (#4819)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-04 14:58:13 +00:00
Dhruwang Jariwala
0e898db710 chore: Remove lib dependency from survey package (#4767)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-04 14:58:00 +00:00
Johannes
40d54d60d4 docs: Release test environment docs (#4842) 2025-03-04 14:57:41 +00:00
Matti Nannt
269e026381 fix: sonarqube action not running in merge queue (#4861) 2025-03-04 15:57:26 +01:00
Matti Nannt
8245f2f6af chore: update sonar-config to properly scan apps/web (#4844) 2025-03-03 18:38:16 +01:00
Matti Nannt
8c07e8b1a8 chore: bump version to 3.3.0 (#4834) 2025-03-01 09:03:42 +01:00
Anshuman Pandey
e94b0845a2 fix: surveys package api calls and styling (#4826)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-01 05:38:43 +00:00
Matti Nannt
4acc85bd12 docs: update kubernetes deployment page (#4835) 2025-02-28 20:38:02 +01:00
Anshuman Pandey
ffa534d5eb fix: cached service in attributes endpoint (#4728)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-02-28 17:00:08 +00:00
Matti Nannt
fccf0f1e39 docs: add smtp configuration page for self-hosters (#4833) 2025-02-28 17:06:21 +01:00
Dhruwang Jariwala
a5d80d1f02 docs: kubernetes (#4830) 2025-02-28 15:34:49 +00:00
Piyush Gupta
803a73afb6 feat: Adds SAML SSO auth using boxyHQ jackson for self-hosters (#4799)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2025-02-28 12:18:59 +00:00
Dhruwang Jariwala
1eb8049d04 chore: Upgrade helm chart (#4808) 2025-02-28 11:51:58 +00:00
Matti Nannt
f9ed0c487f chore: exclude test files from coverage report (#4831) 2025-02-28 12:32:58 +01:00
Anshuman Pandey
fa7d33351f fix: local docker image build (#4758)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-02-28 10:53:23 +00:00
Gaurav Singh
e3084760b8 fix(filter-dropdown): added the search filter in filter dropdown (#4812)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-02-28 10:26:36 +00:00
mintlify[bot]
8e5addad5c docs: Update license page (#4827)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-27 19:08:25 +00:00
Dhruwang Jariwala
6e741018e5 fix: Tweak progress bar (#4820) 2025-02-27 17:51:54 +00:00
Piyush Gupta
98c7c78421 fix: a11y in file upload (#4742)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-02-27 17:23:25 +00:00
Dhruwang Jariwala
16c588138c fix: branch source name (#4825) 2025-02-26 14:32:14 +00:00
Dhruwang Jariwala
1373863af5 fix: current branch name (#4823) 2025-02-26 13:39:09 +00:00
Dhruwang Jariwala
75315ea2c5 fix: tolgee tweaks (#4809)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-02-26 12:06:25 +00:00
Dhruwang Jariwala
9f6fb8a387 feat: optional back button (#4813)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2025-02-26 11:36:16 +00:00
Piyush Gupta
b84d3d5806 fix: hidden field summary row key (#4821) 2025-02-26 11:05:25 +00:00
Dhruwang Jariwala
5c2c1bbfcd fix: removed remove-unused flag (#4814) 2025-02-25 12:52:29 +00:00
Piyush Gupta
54e84858b5 docs: adds query param in single-use-id docs (#4811) 2025-02-25 11:18:59 +00:00
Piyush Gupta
833d0789d7 fix: oauth docs formatting (#4807) 2025-02-25 09:47:52 +00:00
Dhruwang Jariwala
1a974f3dd8 fix: survey placement and close on click outside (#4745)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-02-25 08:33:52 +00:00
Salim B
146173883f docs: Fix formatting and other small tweaks (#4798)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2025-02-25 06:09:45 +00:00
Johannes
ebb02a5723 docs: add suspense to RN (#4801) 2025-02-25 05:54:32 +00:00
Johannes
c96f7fed18 docs: fix images (#4800) 2025-02-24 09:51:15 +00:00
mintlify[bot]
861eff3cd2 docs: Update menu (#4793)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-21 04:52:05 -08:00
Piyush Gupta
b66c0d17d0 feat: adds Is set and Is not set operator for hidden fields in logic editor (#4785)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-02-21 11:47:31 +00:00
Dhruwang Jariwala
0e748050f3 fix: tolgee flow (#4765) 2025-02-21 08:56:06 +00:00
Matti Nannt
ae3524b79f chore: add api v2 draft docs (#4783)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-02-20 16:03:01 +01:00
mintlify[bot]
0ce58b592a docs: fix images in actions and targeting (#4781)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-20 05:21:27 -08:00
mintlify[bot]
578346840e docs: Rewrite Actions and Advanced Targeting Docs (#4779)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-20 12:49:13 +00:00
Piyush Gupta
56bcb46d6c fix: User Management Docs (#4775)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-02-20 09:28:14 +00:00
Piyush Gupta
91405c48e0 chore: remove SHORT_URL_BASE env key (#4766) 2025-02-20 08:22:09 +00:00
Paribesh Nepal
b40dff621a feat: Support HEIC format for images (#4719)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-02-20 04:00:49 +00:00
591 changed files with 29679 additions and 2987 deletions

View File

@@ -1,39 +1,56 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
# **/node_modules
**/node_modules
.pnp
.pnp.js
.pnpm-store/
# testing
coverage
**/coverage
# next.js
**/.next
**/out
**/.next/
**/out/
**/build
# node
**/dist
**/dist/
# misc
.DS_Store
**/.DS_Store
*.pem
Zone.Identifier
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# turbo
.turbo
# local env files
**/.env
**/.env.local
**/.env.development.local
**/.env.test.local
**/.env.production.local
!packages/database/.env
!apps/web/.env
# nixos stuff
# build tools
.turbo
**/*vite.config.*.timestamp-*
# environment specific
.direnv
.vscode
.github
**/.turbo
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
.env
# project specific
packages/lib/uploads
apps/web/public/js
packages/database/migrations
branch.json

View File

@@ -130,6 +130,9 @@ AZUREAD_TENANT_ID=
# OIDC_DISPLAY_NAME=
# OIDC_SIGNING_ALGORITHM=
# Configure SAML SSO
# SAML_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks-saml
# Configure this when you want to ship JS & CSS files from a complete URL instead of the current domain
# ASSET_PREFIX_URL=
@@ -186,6 +189,9 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1

View File

@@ -84,7 +84,7 @@ jobs:
- name: Run App
run: |
NODE_ENV=test pnpm start --filter=@formbricks/web &
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do
if [ $(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/health) -eq 200 ]; then
@@ -136,3 +136,13 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
if: failure()
with:
name: app-logs
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log

View File

@@ -4,8 +4,9 @@ on:
push:
branches:
- main
pull_request:
pull_request_target:
types: [opened, synchronize, reopened]
merge_group:
permissions:
contents: read
jobs:

View File

@@ -3,7 +3,8 @@ permissions:
contents: read
on:
push:
pull_request:
types: [closed]
branches:
- main
@@ -11,10 +12,30 @@ jobs:
tag-production-keys:
name: Tag Production Keys
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # This ensures we get the full git history
- name: Get source branch name
id: branch-name
run: |
# For PR merges, use the head ref from the pull request event
SOURCE_BRANCH="${{ github.head_ref }}"
# Only remove username prefix if needed
if [[ "$SOURCE_BRANCH" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]+/ ]]; then
PREFIX=${SOURCE_BRANCH%%/*}
if [[ ! "$PREFIX" =~ ^(feature|fix|bugfix|hotfix|release|chore|docs|test|refactor|style|perf|build|ci|revert)$ ]]; then
SOURCE_BRANCH=${SOURCE_BRANCH#*/}
fi
fi
echo "SOURCE_BRANCH=$SOURCE_BRANCH" >> $GITHUB_ENV
echo "Detected source branch: $SOURCE_BRANCH"
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -26,17 +47,38 @@ jobs:
- name: Tag Production Keys
run: |
BRANCH_NAME=${GITHUB_REF##*/}
npx tolgee tag \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-extracted \
--filter-tag "draft: ${BRANCH_NAME}" \
--filter-tag "draft:${SOURCE_BRANCH}" \
--tag production \
--untag "draft: ${BRANCH_NAME}"
--untag "draft:${SOURCE_BRANCH}"
- name: Tag Deprecated Keys
- name: Tag unused production keys as Deprecated
run: |
npx tolgee tag \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag production \
--tag deprecated --untag production
- name: Tag unused draft:current-branch keys as Deprecated
run: |
npx tolgee tag \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag "draft:${SOURCE_BRANCH}" \
--tag deprecated --untag "draft:${SOURCE_BRANCH}"
- name: Sync with backup
run: |
npx tolgee sync \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--backup ./tolgee-backup \
--continue-on-warning \
--yes
- name: Upload backup as artifact
uses: actions/upload-artifact@v4
with:
name: tolgee-backup-${{ github.sha }}
path: ./tolgee-backup
retention-days: 90

44
.gitignore vendored
View File

@@ -1,25 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
**/node_modules
.pnp
.pnp.js
.pnpm-store/
# testing
coverage
**/coverage
# next.js
.next/
out/
build
**/.next/
**/out/
**/build
# node
dist/
**/dist/
# misc
.DS_Store
**/.DS_Store
*.pem
Zone.Identifier
# debug
npm-debug.log*
@@ -27,36 +28,29 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
**/.env
**/.env.local
**/.env.development.local
**/.env.test.local
**/.env.production.local
!packages/database/.env
!apps/web/.env
# turbo
# build tools
.turbo
**/*vite.config.*.timestamp-*
# nixos stuff
# environment specific
.direnv
Zone.Identifier
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# uploads
# project specific
packages/lib/uploads
# Vite Timestamps
*vite.config.*.timestamp-*
# js compiled assets
apps/web/public/js
packages/database/migrations
packages/database/migrations
branch.json

View File

@@ -1 +1,2 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ../branch.json
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json

View File

@@ -1 +0,0 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ../branch.json

View File

@@ -1,5 +1,21 @@
pnpm lint-staged
pnpm tolgee-pull || true
echo "{\"branchName\": \"main\"}" > ../branch.json
git add branch.json packages/lib/messages/*.json
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run tolgee-pull if branch.json exists and NEXT_PUBLIC_TOLGEE_API_KEY is not set
if [ -f branch.json ]; then
if [ -z "$NEXT_PUBLIC_TOLGEE_API_KEY" ]; then
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
else
pnpm run tolgee-pull
git add packages/lib/messages
fi
fi

View File

@@ -27,6 +27,10 @@
{
"language": "zh-Hant-TW",
"path": "./packages/lib/messages/zh-Hant-TW.json"
},
{
"language": "pt-PT",
"path": "./packages/lib/messages/pt-PT.json"
}
],
"forceMode": "OVERRIDE"

View File

@@ -6,6 +6,8 @@
"dbaeumer.vscode-eslint", // eslint plugin
"esbenp.prettier-vscode", // prettier plugin
"Prisma.prisma", // syntax|format|completion for prisma
"yzhang.markdown-all-in-one" // nicer markdown support
"yzhang.markdown-all-in-one", // nicer markdown support
"vitest.explorer", // run tests directly from the code window
"sonarsource.sonarlint-vscode" // sonarqube linter for vscode
]
}

View File

@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
Portions of this software are licensed as follows:
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
- All content that resides under the "packages/js/", "packages/react-native/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.

View File

@@ -13,7 +13,7 @@
<h3 align="center">Formbricks</h3>
<p align="center">
Harvest user-insights, build irresistible experiences.
The Open Source Qualtrics Alternative
<br />
<a href="https://formbricks.com/">Website</a>
</p>

3
apps/web/.gitignore vendored
View File

@@ -48,3 +48,6 @@ uploads/
# Sentry Config File
.sentryclirc
# SAML Preloaded Connections
saml-connection/

View File

@@ -33,6 +33,9 @@ ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runt
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit
# ENV NODE_OPTIONS="--max_old_space_size=4096"
# Set the working directory
WORKDIR /app
@@ -41,19 +44,17 @@ WORKDIR /app
# COPY --from=builder /app/out/json/ .
# COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Install the dependencies
# RUN pnpm install
# Prepare the build
COPY . .
# Create a .env file
RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install
# Build the project
# RUN pnpm post-install --filter=@formbricks/web...
RUN pnpm build --filter=@formbricks/web...
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
@@ -76,6 +77,7 @@ WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
@@ -105,6 +107,11 @@ ENV HOSTNAME "0.0.0.0"
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD supercronic -quiet /app/docker/cronjobs & \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js

View File

@@ -1,4 +1,5 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -15,7 +16,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";

View File

@@ -1,4 +1,5 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -19,7 +20,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";

View 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 { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache(
async (environmentId: string): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveys-${environmentId}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -7,6 +7,7 @@ import {
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
@@ -19,7 +20,6 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TIntegrationInput } from "@formbricks/types/integration";
import {

View File

@@ -1,3 +1,4 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -21,7 +22,6 @@ import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getNotionDatabases } from "@formbricks/lib/notion/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";

View File

@@ -1,3 +1,4 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -14,7 +15,6 @@ import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";

View File

@@ -0,0 +1,133 @@
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
SAML_DATABASE_URL: "mock-saml-database-url",
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
getIsOrganizationAIReady: vi.fn(),
getWhiteLabelPermission: vi.fn(),
}));
describe("Page", () => {
const mockParams = { environmentId: "test-environment-id" };
const mockSession = { user: { id: "test-user-id" } };
const mockUser = { id: "test-user-id" } as TUser;
const mockOrganization = { id: "test-organization-id", billing: { plan: "free" } } as TOrganization;
const mockMembership = { role: "owner" } as TMembership;
const mockTranslate = vi.fn((key) => key);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: true,
isManager: false,
isBilling: false,
isMember: false,
});
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
});
it("renders the page with organization settings", async () => {
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
};
const result = await Page(props);
expect(result).toBeTruthy();
});
it("renders if session user id is null", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: null } });
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
};
const result = await Page(props);
expect(result).toBeTruthy();
});
it("throws an error if the session is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow("common.session_not_found");
});
it("throws an error if the organization is not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow(
"common.organization_not_found"
);
});
});

View File

@@ -12,7 +12,8 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import React from "react";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -84,6 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={params.environmentId}
isReadOnly={!isOwnerOrManager}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
fbLogoUrl={FB_LOGO_URL}
user={user}
/>
{isMultiOrgEnabled && (

View File

@@ -2,6 +2,7 @@
import { Badge } from "@/modules/ui/components/badge";
import { useTranslate } from "@tolgee/react";
import React from "react";
import { cn } from "@formbricks/lib/cn";
export const SettingsCard = ({

View File

@@ -1,6 +1,7 @@
"use client";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -12,7 +13,6 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { processResponseData } from "@formbricks/lib/responses";
import { getContactIdentifier } from "@formbricks/lib/utils/contact";
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
@@ -200,13 +200,6 @@ export const generateResponseTableColumns = (
</TooltipTrigger>
<TooltipContent side="bottom" className="font-normal">
{t("environments.surveys.responses.how_to_identify_users")}
<Link
className="underline underline-offset-2 hover:text-slate-900"
href="https://formbricks.com/docs/link-surveys/user-identification"
target="_blank">
{t("common.link_surveys")}
</Link>{" "}
or{" "}
<Link
className="underline underline-offset-2 hover:text-slate-900"
href="https://formbricks.com/docs/app-surveys/user-identification"

View File

@@ -51,9 +51,9 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
<div
key={response.value}
key={`${response.value}-${idx}`}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (

View File

@@ -1,9 +1,9 @@
"use client";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";

View File

@@ -1,10 +1,10 @@
"use client";
import { getQuestionIcon } from "@/modules/survey/lib/questions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { TimerIcon } from "lucide-react";
import { JSX } from "react";
import { getQuestionIcon } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";

View File

@@ -17,11 +17,6 @@ interface LinkTabProps {
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
{
title: t("environments.surveys.summary.identify_users"),
description: t("environments.surveys.summary.identify_users_description"),
link: "https://formbricks.com/docs/link-surveys/user-identification",
},
{
title: t("environments.surveys.summary.data_prefilling"),
description: t("environments.surveys.summary.data_prefilling_description"),

View File

@@ -380,7 +380,7 @@ export const getQuestionSummary = async (
let hasValidAnswer = false;
if (Array.isArray(answer)) {
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
answer.forEach((value) => {
if (value) {
totalSelectionCount++;
@@ -396,7 +396,10 @@ export const getQuestionSummary = async (
hasValidAnswer = true;
}
});
} else if (typeof answer === "string") {
} else if (
typeof answer === "string" &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
) {
if (answer) {
totalSelectionCount++;
if (questionChoices.includes(answer)) {

View File

@@ -14,6 +14,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
@@ -48,6 +49,7 @@ export const QuestionFilterComboBox = ({
const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
const commandRef = React.useRef(null);
const [searchQuery, setSearchQuery] = React.useState<string>("");
const defaultLanguageCode = "default";
useClickOutside(commandRef, () => setOpen(false));
const { t } = useTranslate();
@@ -73,6 +75,12 @@ export const QuestionFilterComboBox = ({
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
const filteredOptions = options?.filter((o) =>
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
return (
<div className="inline-flex w-full flex-row">
{filterOptions && filterOptions?.length <= 1 ? (
@@ -160,10 +168,21 @@ export const QuestionFilterComboBox = ({
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{options?.map((o) => (
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => {
!isMultiple
? onChangeFilterComboBoxValue(

View File

@@ -1,8 +1,8 @@
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { hasOrganizationAccess } from "@formbricks/lib/auth";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -16,7 +16,7 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
// check auth
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const hasAccess = await hasOrganizationAccess(session.user, organizationId);
const hasAccess = await hasOrganizationAccess(session.user.id, organizationId);
if (!hasAccess) throw new AuthorizationError("Unauthorized");
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organizationId);

View File

@@ -1,7 +1,7 @@
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { hasOrganizationAccess } from "@formbricks/lib/auth";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getProject } from "@formbricks/lib/project/service";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
@@ -15,7 +15,7 @@ export const GET = async (_: Request, context: { params: Promise<{ projectId: st
if (!session) throw new AuthenticationError("Not authenticated");
const project = await getProject(projectId);
if (!project) return notFound();
const hasAccess = await hasOrganizationAccess(session.user, project.organizationId);
const hasAccess = await hasOrganizationAccess(session.user.id, project.organizationId);
if (!hasAccess) throw new AuthorizationError("Unauthorized");
// redirect to project's production environment
const environments = await getEnvironments(project.id);

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/ee/auth/saml/api/authorize/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { POST } from "@/modules/ee/auth/saml/api/callback/route";
export { POST };

View File

@@ -0,0 +1,3 @@
import { POST } from "@/modules/ee/auth/saml/api/token/route";
export { POST };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/ee/auth/saml/api/userinfo/route";
export { GET };

View File

@@ -1,16 +1,19 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentIdFromApiKey } from "./lib/api-key";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (environmentId) {
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId,
hashedApiKey,
};
return authentication;
}

View File

@@ -61,6 +61,7 @@ export const getSurveysForEnvironmentState = reactCache(
displayLimit: true,
displayOption: true,
hiddenFields: true,
isBackButtonHidden: true,
triggers: {
select: {
actionClass: {
@@ -72,6 +73,7 @@ export const getSurveysForEnvironmentState = reactCache(
},
displayPercentage: true,
delay: true,
projectOverwrites: true,
},
});

View File

@@ -6,8 +6,7 @@ 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, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
const hashedKey = getHash(apiKey);
@@ -42,7 +41,7 @@ export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Pro
throw error;
}
},
[`getEnvironmentIdFromApiKey-${apiKey}`],
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}

View File

@@ -0,0 +1,15 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
// check for session (browser usage)
let session: Session | null;
if (req && res) {
session = await getServerSession(req, res, authOptions);
} else {
session = await getServerSession(authOptions);
}
if (session && "user" in session) return session.user;
};

View File

@@ -1,4 +1,5 @@
import { getSessionUser, hashApiKey } from "@/app/lib/api/apiHelper";
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,26 @@
import { contactCache } from "@/lib/cache/contact";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
export const doesContactExist = reactCache(
(id: string): Promise<boolean> =>
cache(
async () => {
const contact = await prisma.contact.findFirst({
where: {
id,
},
select: {
id: true,
},
});
return !!contact;
},
[`doesContactExistDisplaysApiV2-${id}`],
{
tags: [contactCache.tag.byId(id)],
}
)()
);

View File

@@ -0,0 +1,54 @@
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { displayCache } from "@formbricks/lib/display/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { DatabaseError } from "@formbricks/types/errors";
import { doesContactExist } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
const { environmentId, contactId, surveyId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
const display = await prisma.display.create({
data: {
survey: {
connect: {
id: surveyId,
},
},
...(contactExists && {
contact: {
connect: {
id: contactId,
},
},
}),
},
select: { id: true, contactId: true, surveyId: true },
});
displayCache.revalidate({
id: display.id,
contactId: display.contactId,
surveyId: display.surveyId,
environmentId,
});
return display;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,55 @@
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
import { InvalidInputError } from "@formbricks/types/errors";
import { createDisplay } from "./lib/display";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
if (inputValidation.data.contactId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
};

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
export const ZDisplayCreateInputV2 = ZDisplayCreateInput.omit({ userId: true }).extend({
contactId: ZId.optional(),
});
export type TDisplayCreateInputV2 = z.infer<typeof ZDisplayCreateInputV2>;

View File

@@ -0,0 +1,3 @@
import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route";
export { OPTIONS, GET };

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -0,0 +1,3 @@
import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,42 @@
import { contactCache } from "@/lib/cache/contact";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
export const getContact = reactCache((contactId: string) =>
cache(
async () => {
const contact = await prisma.contact.findUnique({
where: { id: contactId },
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
if (!contact) {
return null;
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
return {
id: contact.id,
attributes: contactAttributes,
};
},
[`getContact-responses-api-${contactId}`],
{
tags: [contactCache.tag.byId(contactId)],
}
)()
);

View File

@@ -0,0 +1,145 @@
import "server-only";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@formbricks/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { responseCache } from "@formbricks/lib/response/cache";
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { getContact } from "./contact";
export const createResponse = async (responseInput: TResponseInputV2): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const {
environmentId,
language,
contactId,
surveyId,
displayId,
finished,
data,
meta,
singleUseId,
variables,
ttc: initialTtc,
createdAt,
updatedAt,
} = responseInput;
try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
let userId: string | undefined = undefined;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", environmentId);
}
if (contactId) {
contact = await getContact(contactId);
userId = contact?.attributes.userId;
}
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData: Prisma.ResponseCreateInput = {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
data: data,
language: language,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
const responsePrisma = await prisma.response.create({
data: prismaData,
select: responseSelection,
});
const response: TResponse = {
...responsePrisma,
contact: contact
? {
id: contact.id,
userId: contact.attributes.userId,
}
: null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
responseCache.revalidate({
environmentId,
id: response.id,
contactId: contact?.id,
...(singleUseId && { singleUseId }),
userId,
surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
if (IS_FORMBRICKS_CLOUD) {
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
const responsesLimit = organization.billing.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
}
}
}
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,138 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
import { getSurvey } from "@formbricks/lib/survey/service";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { createResponse } from "./lib/response";
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
}
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.contactId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
let response: TResponse;
try {
const meta: TResponseInputV2["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
return responses.successResponse({ id: response.id }, true);
};

View File

@@ -0,0 +1,6 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseInput } from "@formbricks/types/responses";
export const ZResponseInputV2 = ZResponseInput.omit({ userId: true }).extend({ contactId: ZId.nullish() });
export type TResponseInputV2 = z.infer<typeof ZResponseInputV2>;

View File

@@ -0,0 +1,3 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
export { OPTIONS, POST };

View File

@@ -0,0 +1,3 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route";
export { OPTIONS, POST };

View File

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

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route";
export { GET, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/management/responses/route";
export { GET, POST };

View File

@@ -1,17 +0,0 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { XCircleIcon } from "lucide-react";
const Error = ({ error }: { error: Error & { digest?: string } }) => {
const { t } = useTranslate();
return (
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center text-center">
<XCircleIcon height={40} color="red" />
<p className="text-md mt-4 font-bold text-zinc-900">{t("health.degraded")}</p>
<p className="text-sm text-zinc-900">{error.message}</p>
</div>
);
};
export default Error;

View File

@@ -1,54 +0,0 @@
import { getTranslate } from "@/tolgee/server";
import { BadgeCheckIcon } from "lucide-react";
import { Metadata } from "next";
import { prisma } from "@formbricks/database";
export const dynamic = "force-dynamic"; // no caching
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
const checkDatabaseConnection = async () => {
try {
await prisma.$queryRaw`SELECT 1`;
} catch (e) {
console.error("Database connection error:", e);
throw new Error("Database could not be reached");
}
};
/* const checkS3Connection = async () => {
if (!IS_S3_CONFIGURED) {
// dont try connecting if not in use
return;
}
try {
await testS3BucketAccess();
} catch (e) {
throw new Error("S3 Bucket cannot be accessed");
}
}; */
const Page = async () => {
const t = await getTranslate();
await checkDatabaseConnection();
// Skipping S3 check for now until it's fixed
// await checkS3Connection();
return (
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center text-center">
<BadgeCheckIcon height={40} color="green" />
<p className="text-md mt-4 font-bold text-zinc-900">{t("health.healthy")}</p>
</div>
);
};
export default Page;

View File

@@ -0,0 +1,3 @@
export async function GET() {
return Response.json({ status: "ok" });
}

View File

@@ -1,75 +0,0 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
export const hasEnvironmentAccess = async (
req: NextApiRequest,
res: NextApiResponse,
environmentId: string
) => {
if (req.headers["x-api-key"]) {
const ownership = await hasApiEnvironmentAccess(req.headers["x-api-key"].toString(), environmentId);
if (!ownership) {
return false;
}
} else {
const user = await getSessionUser(req, res);
if (!user) {
return false;
}
const ownership = await hasUserEnvironmentAccess(user.id, environmentId);
if (!ownership) {
return false;
}
}
return true;
};
export const hasApiEnvironmentAccess = async (apiKey, environmentId) => {
// write function to check if the API Key has access to the environment
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
},
select: {
environmentId: true,
},
});
if (apiKeyData?.environmentId === environmentId) {
return true;
}
return false;
};
export const hasOrganizationAccess = async (user, organizationId) => {
const membership = await prisma.membership.findUnique({
where: {
userId_organizationId: {
userId: user.id,
organizationId: organizationId,
},
},
});
if (membership) {
return true;
}
return false;
};
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
// check for session (browser usage)
let session: Session | null;
if (req && res) {
session = await getServerSession(req, res, authOptions);
} else {
session = await getServerSession(authOptions);
}
if (session && "user" in session) return session.user;
};

View File

@@ -15,7 +15,8 @@ interface ApiErrorResponse {
| "unauthorized"
| "method_not_allowed"
| "not_authenticated"
| "forbidden";
| "forbidden"
| "too_many_requests";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -247,7 +248,7 @@ const tooManyRequestsResponse = (
return Response.json(
{
code: "internal_server_error",
code: "too_many_requests",
message,
details: {},
} as ApiErrorResponse,

View File

@@ -7064,5 +7064,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
triggers: [],
showLanguageSwitch: false,
followUps: [],
isBackButtonHidden: false,
} as TSurvey;
};

View File

@@ -14,6 +14,11 @@ export const isClientSideApiRoute = (url: string): boolean => {
return regex.test(url);
};
export const isManagementApiRoute = (url: string): boolean => {
const regex = /^\/api\/v\d+\/management\//;
return regex.test(url);
};
export const isShareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
return regex.test(url);

View File

@@ -12,22 +12,36 @@ import {
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { logApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ipAddress } from "@vercel/functions";
import { getToken } from "next-auth/jwt";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
export const middleware = async (request: NextRequest) => {
// issue with next auth types; let's review when new fixes are available
const token = await getToken({ req: request as any });
const enforceHttps = (request: NextRequest): Response | null => {
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
const apiError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 403 });
}
return null;
};
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
@@ -35,13 +49,62 @@ export const middleware = async (request: NextRequest) => {
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) {
return NextResponse.json({ error: "Invalid callback URL" });
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (token && callbackUrl) {
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
}
if (process.env.NODE_ENV !== "production" || RATE_LIMITING_DISABLED) {
return NextResponse.next();
return null;
};
const applyRateLimiting = (request: NextRequest, ip: string) => {
if (isLoginRoute(request.nextUrl.pathname)) {
loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
shareUrlLimiter(`share-${ip}`);
}
};
export const middleware = async (originalRequest: NextRequest) => {
// Create a new Request object to override headers and add a unique request ID header
const request = new NextRequest(originalRequest, {
headers: new Headers(originalRequest.headers),
});
request.headers.set("x-request-id", uuidv4());
// Create a new NextResponse object to forward the new request with headers
const nextResponseWithCustomHeader = NextResponse.next({
request: {
headers: request.headers,
},
});
// Enforce HTTPS for management endpoints
if (isManagementApiRoute(request.nextUrl.pathname)) {
const httpsResponse = enforceHttps(request);
if (httpsResponse) return httpsResponse;
}
// Handle authentication
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) {
return nextResponseWithCustomHeader;
}
let ip =
@@ -51,32 +114,19 @@ export const middleware = async (request: NextRequest) => {
if (ip) {
try {
if (isLoginRoute(request.nextUrl.pathname)) {
await loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
await signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
await verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
await forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
await shareUrlLimiter(`share-${ip}`);
}
return NextResponse.next();
applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
console.log(`Rate Limiting IP: ${ip}`);
return NextResponse.json({ error: "Too many requests, Please try after a while!" }, { status: 429 });
const apiError: ApiErrorResponseV2 = {
type: "too_many_requests",
details: [{ field: "", issue: "Too many requests. Please try again later." }],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 429 });
}
}
return NextResponse.next();
return nextResponseWithCustomHeader;
};
export const config = {
@@ -94,5 +144,7 @@ export const config = {
"/api/packages/:path*",
"/auth/verification-requested",
"/auth/forgot-password",
"/api/v1/management/:path*",
"/api/v2/management/:path*",
],
};

View File

@@ -8,7 +8,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
return (
<Input
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-center text-slate-800 caret-transparent"
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
defaultValue={surveyUrl}
/>
);

View File

@@ -71,7 +71,7 @@ export const ShareSurveyLink = ({
return (
<div
className={`flex max-w-full flex-col items-center justify-center space-x-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
<SurveyLinkDisplay surveyUrl={surveyUrl} />
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
<div className="mt-2 flex items-center justify-center space-x-2">
<LanguageDropdown survey={survey} setLanguage={setLanguage} locale={locale} />
<Button

View File

@@ -0,0 +1,70 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@formbricks/lib/constants";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export type RateLimitHelper = {
identifier: string;
opts?: LimitOptions;
/**
* Using a callback instead of a regular return to provide headers even
* when the rate limit is reached and an error is thrown.
**/
onRateLimiterResponse?: (response: RatelimitResponse) => void;
};
let warningDisplayed = false;
/** Prevent flooding the logs while testing/building */
function logOnce(message: string) {
if (warningDisplayed) return;
console.warn(message);
warningDisplayed = true;
}
export function rateLimiter() {
if (RATE_LIMITING_DISABLED) {
logOnce("Rate limiting disabled");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
if (!UNKEY_ROOT_KEY) {
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
const timeout = {
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
ms: 5000,
};
const limiter = {
api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
namespace: "api",
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
timeout,
}),
};
async function rateLimit({ identifier, opts }: RateLimitHelper) {
return await limiter.api.limit(identifier, opts);
}
return rateLimit;
}
export const checkRateLimitAndThrowError = async ({
identifier,
opts,
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
const response = await rateLimiter()({ identifier, opts });
const { success } = response;
if (!success) {
return err({
type: "too_many_requests",
});
}
return okVoid();
};

View File

@@ -0,0 +1,270 @@
import { ApiErrorDetails, ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiSuccessResponse } from "@/modules/api/v2/types/api-success";
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponseV2;
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
const badRequestResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 400,
message: "Bad Request",
details,
},
},
{
status: 400,
headers,
}
);
};
const unauthorizedResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 401,
message: "Unauthorized",
},
},
{
status: 401,
headers,
}
);
};
const forbiddenResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 403,
message: "Forbidden",
},
},
{
status: 403,
headers,
}
);
};
const notFoundResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 404,
message: "Not Found",
details,
},
},
{
status: 404,
headers,
}
);
};
const conflictResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 409,
message: "Conflict",
},
},
{
status: 409,
headers,
}
);
};
const unprocessableEntityResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details: ApiErrorDetails;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 422,
message: "Unprocessable Entity",
details,
},
},
{
status: 422,
headers,
}
);
};
const tooManyRequestsResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 429,
message: "Too Many Requests",
},
},
{
status: 429,
headers,
}
);
};
const internalServerErrorResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 500,
message: "Internal Server Error",
details,
},
},
{
status: 500,
headers,
}
);
};
const successResponse = ({
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: 200,
headers,
}
);
};
export const responses = {
badRequestResponse,
unauthorizedResponse,
forbiddenResponse,
notFoundResponse,
conflictResponse,
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
successResponse,
};

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@unkey/ratelimit", () => ({
Ratelimit: vi.fn(),
}));
describe("when rate limiting is disabled", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@formbricks/lib/constants");
vi.doMock("@formbricks/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: true,
}));
});
test("should log a warning once and return a stubbed response", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const res1 = await rateLimiter()({ identifier: "test-id" });
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(warnSpy).toHaveBeenCalledWith("Rate limiting disabled");
// Subsequent calls won't log again.
await rateLimiter()({ identifier: "another-id" });
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
});
describe("when UNKEY_ROOT_KEY is missing", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@formbricks/lib/constants");
vi.doMock("@formbricks/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "",
}));
});
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "test-id" });
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(warnSpy).toHaveBeenCalledWith("Disabled due to not finding UNKEY_ROOT_KEY env variable");
warnSpy.mockRestore();
});
});
describe("when rate limiting is active (enabled)", () => {
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
let limitMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@formbricks/lib/constants");
vi.doMock("@formbricks/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "valid-key",
}));
limitMock = vi.fn().mockResolvedValue(mockResponse);
const RatelimitMock = vi.fn().mockImplementation(() => {
return { limit: limitMock };
});
vi.doMock("@unkey/ratelimit", () => ({
Ratelimit: RatelimitMock,
}));
});
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
const { rateLimiter } = await import("../rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
expect(res).toEqual(mockResponse);
});
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(true);
});
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "too_many_requests" });
}
});
});

View File

@@ -0,0 +1,183 @@
import { describe, expect, test } from "vitest";
import { responses } from "../response";
describe("API Responses", () => {
describe("badRequestResponse", () => {
test("return a 400 response with error details", async () => {
const details = [{ field: "param", issue: "invalid" }];
const res = responses.badRequestResponse({ details });
expect(res.status).toBe(400);
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
const body = await res.json();
expect(body).toEqual({
error: {
code: 400,
message: "Bad Request",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.badRequestResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("unauthorizedResponse", () => {
test("return a 401 response with the proper error message", async () => {
const res = responses.unauthorizedResponse();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({
error: {
code: 401,
message: "Unauthorized",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.unauthorizedResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("forbiddenResponse", () => {
test("return a 403 response", async () => {
const res = responses.forbiddenResponse();
expect(res.status).toBe(403);
const body = await res.json();
expect(body).toEqual({
error: {
code: 403,
message: "Forbidden",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.forbiddenResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("notFoundResponse", () => {
test("return a 404 response with error details", async () => {
const details = [{ field: "resource", issue: "not found" }];
const res = responses.notFoundResponse({ details });
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({
error: {
code: 404,
message: "Not Found",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.notFoundResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("conflictResponse", () => {
test("return a 409 response", async () => {
const res = responses.conflictResponse();
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toEqual({
error: {
code: 409,
message: "Conflict",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.conflictResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("unprocessableEntityResponse", () => {
test("return a 422 response with error details", async () => {
const details = [{ field: "data", issue: "malformed" }];
const res = responses.unprocessableEntityResponse({ details });
expect(res.status).toBe(422);
const body = await res.json();
expect(body).toEqual({
error: {
code: 422,
message: "Unprocessable Entity",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.unprocessableEntityResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("tooManyRequestsResponse", () => {
test("return a 429 response", async () => {
const res = responses.tooManyRequestsResponse();
expect(res.status).toBe(429);
const body = await res.json();
expect(body).toEqual({
error: {
code: 429,
message: "Too Many Requests",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.tooManyRequestsResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("internalServerErrorResponse", () => {
test("return a 500 response with error details", async () => {
const details = [{ field: "server", issue: "crashed" }];
const res = responses.internalServerErrorResponse({ details });
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({
error: {
code: 500,
message: "Internal Server Error",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.internalServerErrorResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("successResponse", () => {
test("return a success response with the provided data", async () => {
const data = { foo: "bar" };
const meta = { page: 1 };
const res = responses.successResponse({ data, meta });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual(data);
expect(body.meta).toEqual(meta);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.successResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
});

View File

@@ -0,0 +1,201 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
// Add the request id header
mockRequest.headers.set("x-request-id", "123");
describe("utils", () => {
describe("handleApiError", () => {
test('return bad request response for "bad_request" error', async () => {
const details = [{ field: "param", issue: "invalid" }];
const error: ApiErrorResponseV2 = { type: "bad_request", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error.code).toBe(400);
expect(body.error.message).toBe("Bad Request");
expect(body.error.details).toEqual(details);
});
test('return unauthorized response for "unauthorized" error', async () => {
const error: ApiErrorResponseV2 = { type: "unauthorized" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error.code).toBe(401);
expect(body.error.message).toBe("Unauthorized");
});
test('return forbidden response for "forbidden" error', async () => {
const error: ApiErrorResponseV2 = { type: "forbidden" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error.code).toBe(403);
expect(body.error.message).toBe("Forbidden");
});
test('return not found response for "not_found" error', async () => {
const details = [{ field: "resource", issue: "not found" }];
const error: ApiErrorResponseV2 = { type: "not_found", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error.code).toBe(404);
expect(body.error.message).toBe("Not Found");
expect(body.error.details).toEqual(details);
});
test('return conflict response for "conflict" error', async () => {
const error: ApiErrorResponseV2 = { type: "conflict" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(409);
const body = await response.json();
expect(body.error.code).toBe(409);
expect(body.error.message).toBe("Conflict");
});
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
const details = [{ field: "data", issue: "malformed" }];
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(422);
const body = await response.json();
expect(body.error.code).toBe(422);
expect(body.error.message).toBe("Unprocessable Entity");
expect(body.error.details).toEqual(details);
});
test('return too many requests response for "too_many_requests" error', async () => {
const error: ApiErrorResponseV2 = { type: "too_many_requests" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(429);
const body = await response.json();
expect(body.error.code).toBe(429);
expect(body.error.message).toBe("Too Many Requests");
});
test('return internal server error response for "internal_server_error" error with default message', async () => {
const details = [{ field: "server", issue: "error occurred" }];
const error: ApiErrorResponseV2 = { type: "internal_server_error", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error.code).toBe(500);
expect(body.error.message).toBe("Internal Server Error");
expect(body.error.details).toEqual([
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
]);
});
});
describe("formatZodError", () => {
test("correctly format a Zod error", () => {
const zodError = {
issues: [
{
path: ["field1"],
message: "Invalid value for field1",
},
{
path: ["field2", "subfield"],
message: "Field2 subfield is required",
},
],
} as ZodError;
const formatted = formatZodError(zodError);
expect(formatted).toEqual([
{ field: "field1", issue: "Invalid value for field1" },
{ field: "field2.subfield", issue: "Field2 subfield is required" },
]);
});
test("return an empty array if there are no issues", () => {
const zodError = { issues: [] } as unknown as ZodError;
const formatted = formatZodError(zodError);
expect(formatted).toEqual([]);
});
});
describe("logApiRequest", () => {
test("logs API request details", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc&safeParam=value");
mockRequest.headers.set("x-request-id", "123");
logApiRequest(mockRequest, 200, 100);
expect(consoleLogSpy).toHaveBeenCalledWith(
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n correlationId: 123\n queryParams: {"safeParam":"value"}`
);
consoleLogSpy.mockRestore();
});
test("logs API request details without correlationId and without safe query params", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc");
mockRequest.headers.delete("x-request-id");
logApiRequest(mockRequest, 200, 100);
expect(consoleLogSpy).toHaveBeenCalledWith(
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n queryParams: {}`
);
consoleLogSpy.mockRestore();
});
});
describe("logApiError", () => {
test("logs API error details", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
logApiError(mockRequest, error);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`[API ERROR DETAILS]\n correlationId: 123\n error: ${JSON.stringify(error, null, 2)}`
);
consoleErrorSpy.mockRestore();
});
test("logs API error details without correlationId", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.delete("x-request-id");
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
logApiError(mockRequest, error);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`[API ERROR DETAILS]\n error: ${JSON.stringify(error, null, 2)}`
);
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,65 @@
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ZodError } from "zod";
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
logApiError(request, err);
switch (err.type) {
case "bad_request":
return responses.badRequestResponse({ details: err.details });
case "unauthorized":
return responses.unauthorizedResponse();
case "forbidden":
return responses.forbiddenResponse();
case "not_found":
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse();
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":
return responses.tooManyRequestsResponse();
default:
// Replace with a generic error message, because we don't want to expose internal errors to API users.
return responses.internalServerErrorResponse({
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
});
}
};
export const formatZodError = (error: ZodError) => {
return error.issues.map((issue) => ({
field: issue.path.join("."),
issue: issue.message,
}));
};
export const logApiRequest = (request: Request, responseStatus: number, duration: number): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
const correlationId = request.headers.get("x-request-id") || "";
const queryParams = Object.fromEntries(url.searchParams.entries());
const sensitiveParams = ["apikey", "token", "secret"];
const safeQueryParams = Object.fromEntries(
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
);
console.log(
`[API REQUEST DETAILS] ${method} ${path} - ${responseStatus} - ${duration}ms${correlationId ? `\n correlationId: ${correlationId}` : ""}\n queryParams: ${JSON.stringify(safeQueryParams)}`
);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") || "";
console.error(
`[API ERROR DETAILS]${correlationId ? `\n correlationId: ${correlationId}` : ""}\n error: ${JSON.stringify(error, null, 2)}`
);
};

View File

@@ -0,0 +1,106 @@
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) throw 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: "forbidden",
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) || {};
console.log("paramsObject: ", paramsObject);
const paramsResult = schemas.params.safeParse(paramsObject);
if (!paramsResult.success) {
throw err({
type: "unprocessable_entity",
details: formatZodError(paramsResult.error),
});
}
parsedInput.params = paramsResult.data as ParsedSchemas<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);
}
};

View File

@@ -0,0 +1,33 @@
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",
});
};

View File

@@ -0,0 +1,32 @@
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 startTime = Date.now();
const response = await apiWrapper({
request,
schemas,
externalParams,
rateLimit,
handler,
});
const duration = Date.now() - startTime;
logApiRequest(request, response.status, duration);
return response;
};

View File

@@ -0,0 +1,18 @@
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();
};

View File

@@ -0,0 +1,300 @@
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
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 { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
vi.mock("../authenticate-request", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
checkRateLimitAndThrowError: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
}));
describe("apiWrapper", () => {
it("should handle request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
});
it("should handle errors and return error response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(err({ type: "unauthorized" }));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 401 }));
const handler = vi.fn();
const response = await apiWrapper({
request,
handler,
});
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
});
it("should parse body schema correctly", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ key: "value" }),
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
parsedInput: { body: { key: "value" } },
})
);
});
it("should handle body schema errors", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ key: 123 }),
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
const querySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
schemas: { query: querySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
parsedInput: { query: { key: "value" } },
})
);
});
it("should handle query schema errors", async () => {
const request = new Request("http://localhost?foo%ZZ=abc");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const querySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { query: querySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should parse params schema correctly", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
schemas: { params: paramsSchema },
externalParams: Promise.resolve({ key: "value" }),
rateLimit: false,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
parsedInput: { params: { key: "value" } },
})
);
});
it("should handle no external params", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { params: paramsSchema },
externalParams: undefined,
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should handle params schema errors", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { params: paramsSchema },
externalParams: Promise.resolve({ notKey: "value" }),
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should handle rate limit errors", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
);
vi.mocked(handleApiError).mockImplementation(
(_request: Request, _error: ApiErrorResponseV2): Response =>
new Response("rate limit exceeded", { status: 429 })
);
const handler = vi.fn();
const response = await apiWrapper({
request,
handler,
});
expect(response.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
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" });
}
});
});

View File

@@ -0,0 +1,32 @@
import { logApiRequest } from "@/modules/api/v2/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { apiWrapper } from "../api-wrapper";
import { authenticatedApiClient } from "../authenticated-api-client";
vi.mock("../api-wrapper", () => ({
apiWrapper: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
logApiRequest: vi.fn(),
}));
describe("authenticatedApiClient", () => {
it("should log request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(apiWrapper).mockResolvedValue(new Response("ok", { status: 200 }));
vi.mocked(logApiRequest).mockReturnValue();
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await authenticatedApiClient({
request,
handler,
});
expect(response.status).toBe(200);
expect(logApiRequest).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
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" });
}
});
});

View File

@@ -0,0 +1,79 @@
import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKey",
summary: "Get a contact attribute key",
description: "Gets a contact attribute key from the database.",
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
}),
},
tags: ["Management API > Contact Attribute Keys"],
responses: {
"200": {
description: "Contact attribute key retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact attribute key deleted successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};
export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttributeKey",
summary: "Update a contact attribute key",
description: "Updates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The contact attribute key to update",
content: {
"application/json": {
schema: ZContactAttributeKeyInput,
},
},
},
responses: {
"200": {
description: "Contact attribute key updated successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};

View File

@@ -0,0 +1,65 @@
import {
deleteContactAttributeKeyEndpoint,
getContactAttributeKeyEndpoint,
updateContactAttributeKeyEndpoint,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi";
import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys",
summary: "Get contact attribute keys",
description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter,
},
responses: {
"200": {
description: "Contact attribute keys retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttributeKey),
},
},
},
},
};
export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttributeKey",
summary: "Create a contact attribute key",
description: "Creates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
requestBody: {
required: true,
description: "The contact attribute key to create",
content: {
"application/json": {
schema: ZContactAttributeKeyInput,
},
},
},
responses: {
"201": {
description: "Contact attribute key created successfully.",
},
},
};
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
"/contact-attribute-keys": {
get: getContactAttributeKeysEndpoint,
post: createContactAttributeKeyEndpoint,
},
"/contact-attribute-keys/{id}": {
get: getContactAttributeKeyEndpoint,
put: updateContactAttributeKeyEndpoint,
delete: deleteContactAttributeKeyEndpoint,
},
};

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
export const ZGetContactAttributeKeysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
type: true,
environmentId: true,
}).openapi({
ref: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeKeyInput = z.infer<typeof ZContactAttributeKeyInput>;

View File

@@ -0,0 +1,79 @@
import { ZContactAttributeInput } from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttribute",
summary: "Get a contact attribute",
description: "Gets a contact attribute from the database.",
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
tags: ["Management API > Contact Attributes"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttribute",
summary: "Delete a contact attribute",
description: "Deletes a contact attribute from the database.",
tags: ["Management API > Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttribute",
summary: "Update a contact attribute",
description: "Updates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};

View File

@@ -0,0 +1,65 @@
import {
deleteContactAttributeEndpoint,
getContactAttributeEndpoint,
updateContactAttributeEndpoint,
} from "@/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi";
import {
ZContactAttributeInput,
ZGetContactAttributesFilter,
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributes",
summary: "Get contact attributes",
description: "Gets contact attributes from the database.",
tags: ["Management API > Contact Attributes"],
requestParams: {
query: ZGetContactAttributesFilter,
},
responses: {
"200": {
description: "Contact attributes retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttribute),
},
},
},
},
};
export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttribute",
summary: "Create a contact attribute",
description: "Creates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
requestBody: {
required: true,
description: "The contact attribute to create",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"201": {
description: "Contact attribute created successfully.",
},
},
};
export const contactAttributePaths: ZodOpenApiPathsObject = {
"/contact-attributes": {
get: getContactAttributesEndpoint,
post: createContactAttributeEndpoint,
},
"/contact-attributes/{id}": {
get: getContactAttributeEndpoint,
put: updateContactAttributeEndpoint,
delete: deleteContactAttributeEndpoint,
},
};

View File

@@ -0,0 +1,34 @@
import { z } from "zod";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const ZGetContactAttributesFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactAttributeInput = ZContactAttribute.pick({
attributeKeyId: true,
contactId: true,
value: true,
}).openapi({
ref: "contactAttributeInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeInput = z.infer<typeof ZContactAttributeInput>;

View File

@@ -0,0 +1,79 @@
import { ZContactInput } from "@/modules/api/v2/management/contacts/types/contacts";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactEndpoint: ZodOpenApiOperationObject = {
operationId: "getContact",
summary: "Get a contact",
description: "Gets a contact from the database.",
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
tags: ["Management API > Contacts"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const deleteContactEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContact",
summary: "Delete a contact",
description: "Deletes a contact from the database.",
tags: ["Management API > Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const updateContactEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContact",
summary: "Update a contact",
description: "Updates a contact in the database.",
tags: ["Management API > Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};

View File

@@ -0,0 +1,67 @@
import {
deleteContactEndpoint,
getContactEndpoint,
updateContactEndpoint,
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactsEndpoint: ZodOpenApiOperationObject = {
operationId: "getContacts",
summary: "Get contacts",
description: "Gets contacts from the database.",
requestParams: {
query: ZGetContactsFilter,
},
tags: ["Management API > Contacts"],
responses: {
"200": {
description: "Contacts retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContact),
},
},
},
},
};
export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description: "Creates a contact in the database.",
tags: ["Management API > Contacts"],
requestBody: {
required: true,
description: "The contact to create",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"201": {
description: "Contact created successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
get: getContactsEndpoint,
post: createContactEndpoint,
},
"/contacts/{id}": {
get: getContactEndpoint,
put: updateContactEndpoint,
delete: deleteContactEndpoint,
},
};

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { ZContact } from "@formbricks/database/zod/contact";
export const ZGetContactsFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactInput = ZContact.pick({
userId: true,
environmentId: true,
})
.partial({
userId: true,
})
.openapi({
ref: "contactCreate",
description: "A contact to create",
});
export type TContactInput = z.infer<typeof ZContactInput>;

View File

@@ -0,0 +1,44 @@
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)],
}
)();
});

View File

@@ -0,0 +1,16 @@
import { fetchEnvironmentId } from "@/modules/api/v2/management/lib/services";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Result, ok } from "@formbricks/types/error-handlers";
export const getEnvironmentId = async (
id: string,
isResponseId: boolean
): Promise<Result<string, ApiErrorResponseV2>> => {
const result = await fetchEnvironmentId(id, isResponseId);
if (!result.ok) {
return result;
}
return ok(result.data.environmentId);
};

View File

@@ -0,0 +1,22 @@
import {
deleteResponseEndpoint,
getResponseEndpoint,
updateResponseEndpoint,
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import {
createResponseEndpoint,
getResponsesEndpoint,
} from "@/modules/api/v2/management/responses/lib/openapi";
import { ZodOpenApiPathsObject } from "zod-openapi";
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,
},
};

View File

@@ -0,0 +1,43 @@
"use server";
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 { responseCache } from "@formbricks/lib/response/cache";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: boolean) =>
cache(
async (): Promise<Result<{ environmentId: string }, ApiErrorResponseV2>> => {
try {
const result = await prisma.survey.findFirst({
where: isResponseId ? { responses: { some: { id } } } : { id },
select: {
environmentId: true,
},
});
if (!result) {
return err({
type: "not_found",
details: [{ field: isResponseId ? "response" : "survey", issue: "not found" }],
});
}
return ok({ environmentId: result.environmentId });
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: isResponseId ? "response" : "survey", issue: error.message }],
});
}
},
[`services-getEnvironmentId-${id}-${isResponseId}`],
{
tags: [responseCache.tag.byId(id), responseNoteCache.tag.byResponseId(id), surveyCache.tag.byId(id)],
}
)()
);

View File

@@ -0,0 +1,2 @@
export const apiKey = "test-api-key";
export const environmentId = "h8bfgyetrmvdh5v4cvexogd9";

View File

@@ -0,0 +1,81 @@
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" }]);
}
});
});

View File

@@ -0,0 +1,43 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { getEnvironmentId } from "../helper";
import { fetchEnvironmentId } from "../services";
vi.mock("../services", () => ({
fetchEnvironmentId: vi.fn(),
}));
describe("Helper Functions", () => {
it("should return environmentId for surveyId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
const result = await getEnvironmentId("survey-id", false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe("env-id");
}
});
it("should return environmentId for responseId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
const result = await getEnvironmentId("response-id", true);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe("env-id");
}
});
it("should return error if getSurveyAndEnvironmentId fails", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(
err({ type: "not_found" } as unknown as ApiErrorResponseV2)
);
const result = await getEnvironmentId("invalid-id", true);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
});

View File

@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { fetchEnvironmentId } from "../services";
vi.mock("@formbricks/database", () => ({
prisma: {
survey: { findFirst: vi.fn() },
},
}));
describe("Services", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getSurveyAndEnvironmentId", () => {
test("should return surveyId and environmentId for responseId", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
environmentId: "env-id",
responses: [{ surveyId: "survey-id" }],
});
const result = await fetchEnvironmentId("response-id", true);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ environmentId: "env-id" });
}
});
test("should return surveyId and environmentId for surveyId", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
id: "survey-id",
environmentId: "env-id",
});
const result = await fetchEnvironmentId("survey-id", false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ environmentId: "env-id" });
}
});
test("should return error if response is not found", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue(null);
const result = await fetchEnvironmentId("invalid-id", true);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
test("should return error if survey is not found", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue(null);
const result = await fetchEnvironmentId("invalid-id", false);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
test("should return internal_server_error if prisma query fails for responseId", async () => {
vi.mocked(prisma.survey.findFirst).mockRejectedValue(new Error("Internal server error"));
const result = await fetchEnvironmentId("response-id", true);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
test("should return internal_server_error if prisma query fails for surveyId", async () => {
vi.mocked(prisma.survey.findFirst).mockRejectedValue(new Error("Internal server error"));
const result = await fetchEnvironmentId("survey-id", false);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
});

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