mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 15:10:36 -06:00
Compare commits
62 Commits
feat/heic
...
fix/sonar-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9650ef12f | ||
|
|
4424a8a21d | ||
|
|
eb030f9ed6 | ||
|
|
333372d61c | ||
|
|
48a92f3e55 | ||
|
|
ddc767e53e | ||
|
|
432425ea59 | ||
|
|
6075fd3ef8 | ||
|
|
f099a46f83 | ||
|
|
fe54ef66c6 | ||
|
|
4eb0e930f6 | ||
|
|
fae925aa25 | ||
|
|
764a3d2fde | ||
|
|
b5a51f1304 | ||
|
|
140aee749b | ||
|
|
4113dd1873 | ||
|
|
0e0d3780d3 | ||
|
|
38ff01aedc | ||
|
|
cdf687ad80 | ||
|
|
a399fc7f80 | ||
|
|
c54a48e70b | ||
|
|
884b6f12ae | ||
|
|
5cae0febc9 | ||
|
|
0e898db710 | ||
|
|
40d54d60d4 | ||
|
|
269e026381 | ||
|
|
8245f2f6af | ||
|
|
8c07e8b1a8 | ||
|
|
e94b0845a2 | ||
|
|
4acc85bd12 | ||
|
|
ffa534d5eb | ||
|
|
fccf0f1e39 | ||
|
|
a5d80d1f02 | ||
|
|
803a73afb6 | ||
|
|
1eb8049d04 | ||
|
|
f9ed0c487f | ||
|
|
fa7d33351f | ||
|
|
e3084760b8 | ||
|
|
8e5addad5c | ||
|
|
6e741018e5 | ||
|
|
98c7c78421 | ||
|
|
16c588138c | ||
|
|
1373863af5 | ||
|
|
75315ea2c5 | ||
|
|
9f6fb8a387 | ||
|
|
b84d3d5806 | ||
|
|
5c2c1bbfcd | ||
|
|
54e84858b5 | ||
|
|
833d0789d7 | ||
|
|
1a974f3dd8 | ||
|
|
146173883f | ||
|
|
ebb02a5723 | ||
|
|
c96f7fed18 | ||
|
|
861eff3cd2 | ||
|
|
b66c0d17d0 | ||
|
|
0e748050f3 | ||
|
|
ae3524b79f | ||
|
|
0ce58b592a | ||
|
|
578346840e | ||
|
|
56bcb46d6c | ||
|
|
91405c48e0 | ||
|
|
b40dff621a |
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
12
.github/workflows/e2e.yml
vendored
12
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/sonarqube.yml
vendored
3
.github/workflows/sonarqube.yml
vendored
@@ -4,8 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
52
.github/workflows/tolgee.yml
vendored
52
.github/workflows/tolgee.yml
vendored
@@ -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
44
.gitignore
vendored
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ../branch.json
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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.
|
||||
|
||||
|
||||
@@ -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
3
apps/web/.gitignore
vendored
@@ -48,3 +48,6 @@ uploads/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
# SAML Preloaded Connections
|
||||
saml-connection/
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
3
apps/web/app/api/auth/saml/authorize/route.ts
Normal file
3
apps/web/app/api/auth/saml/authorize/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/authorize/route";
|
||||
|
||||
export { GET };
|
||||
3
apps/web/app/api/auth/saml/callback/route.ts
Normal file
3
apps/web/app/api/auth/saml/callback/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/callback/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/token/route.ts
Normal file
3
apps/web/app/api/auth/saml/token/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/token/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/userinfo/route.ts
Normal file
3
apps/web/app/api/auth/saml/userinfo/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/userinfo/route";
|
||||
|
||||
export { GET };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
|
||||
15
apps/web/app/api/v1/management/me/lib/utils.ts
Normal file
15
apps/web/app/api/v1/management/me/lib/utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
55
apps/web/app/api/v2/client/[environmentId]/displays/route.ts
Normal file
55
apps/web/app/api/v2/client/[environmentId]/displays/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route";
|
||||
|
||||
export { OPTIONS, GET };
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
138
apps/web/app/api/v2/client/[environmentId]/responses/route.ts
Normal file
138
apps/web/app/api/v2/client/[environmentId]/responses/route.ts
Normal 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);
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
3
apps/web/app/api/v2/client/[environmentId]/user/route.ts
Normal file
3
apps/web/app/api/v2/client/[environmentId]/user/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
||||
|
||||
export { POST, OPTIONS };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
3
apps/web/app/api/v2/management/responses/route.ts
Normal file
3
apps/web/app/api/v2/management/responses/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/responses/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
apps/web/app/health/route.ts
Normal file
3
apps/web/app/health/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function GET() {
|
||||
return Response.json({ status: "ok" });
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -7064,5 +7064,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
} as TSurvey;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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*",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
70
apps/web/modules/api/v2/lib/rate-limit.ts
Normal file
70
apps/web/modules/api/v2/lib/rate-limit.ts
Normal 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();
|
||||
};
|
||||
270
apps/web/modules/api/v2/lib/response.ts
Normal file
270
apps/web/modules/api/v2/lib/response.ts
Normal 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,
|
||||
};
|
||||
107
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal file
107
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
183
apps/web/modules/api/v2/lib/tests/response.test.ts
Normal file
183
apps/web/modules/api/v2/lib/tests/response.test.ts
Normal 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("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
201
apps/web/modules/api/v2/lib/tests/utils.test.ts
Normal file
201
apps/web/modules/api/v2/lib/tests/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/web/modules/api/v2/lib/utils.ts
Normal file
65
apps/web/modules/api/v2/lib/utils.ts
Normal 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)}`
|
||||
);
|
||||
};
|
||||
106
apps/web/modules/api/v2/management/auth/api-wrapper.ts
Normal file
106
apps/web/modules/api/v2/management/auth/api-wrapper.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
67
apps/web/modules/api/v2/management/contacts/lib/openapi.ts
Normal file
67
apps/web/modules/api/v2/management/contacts/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
44
apps/web/modules/api/v2/management/lib/api-key.ts
Normal file
44
apps/web/modules/api/v2/management/lib/api-key.ts
Normal 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)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
16
apps/web/modules/api/v2/management/lib/helper.ts
Normal file
16
apps/web/modules/api/v2/management/lib/helper.ts
Normal 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);
|
||||
};
|
||||
22
apps/web/modules/api/v2/management/lib/openapi.ts
Normal file
22
apps/web/modules/api/v2/management/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
43
apps/web/modules/api/v2/management/lib/services.ts
Normal file
43
apps/web/modules/api/v2/management/lib/services.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export const apiKey = "test-api-key";
|
||||
export const environmentId = "h8bfgyetrmvdh5v4cvexogd9";
|
||||
81
apps/web/modules/api/v2/management/lib/tests/api-key.test.ts
Normal file
81
apps/web/modules/api/v2/management/lib/tests/api-key.test.ts
Normal 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" }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
43
apps/web/modules/api/v2/management/lib/tests/helper.test.ts
Normal file
43
apps/web/modules/api/v2/management/lib/tests/helper.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user