mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-25 16:00:16 -06:00
Compare commits
24 Commits
v3.7.1
...
fix/sonarq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7540c64fdf | ||
|
|
3b815e22e3 | ||
|
|
4d4a5c0e64 | ||
|
|
0e89293974 | ||
|
|
c306911b3a | ||
|
|
4f276f0095 | ||
|
|
81fc97c7e9 | ||
|
|
785c5a59c6 | ||
|
|
25ecfaa883 | ||
|
|
38e2c019fa | ||
|
|
15878a4ac5 | ||
|
|
9802536ded | ||
|
|
2c7f92a4d7 | ||
|
|
c653841037 | ||
|
|
ec314c14ea | ||
|
|
c03e60ac0b | ||
|
|
cbf2343143 | ||
|
|
9d9b3ac543 | ||
|
|
591b35a70b | ||
|
|
f0c7b881d3 | ||
|
|
3fd5515db1 | ||
|
|
f32401afd6 | ||
|
|
1b9d91f1e8 | ||
|
|
1f039d707c |
@@ -117,7 +117,7 @@ IMPRINT_URL=
|
||||
IMPRINT_ADDRESS=
|
||||
|
||||
# Configure Turnstile in signup flow
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Configure Github Login
|
||||
|
||||
163
.github/workflows/docker-build-validation.yml
vendored
Normal file
163
.github/workflows/docker-build-validation.yml
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
name: Docker Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-docker-build:
|
||||
name: Validate Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Add PostgreSQL service container
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: formbricks
|
||||
ports:
|
||||
- 5432:5432
|
||||
# Health check to ensure PostgreSQL is ready before using it
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: formbricks-test:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Verify PostgreSQL Connection
|
||||
run: |
|
||||
echo "Verifying PostgreSQL connection..."
|
||||
# Install PostgreSQL client to test connection
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
|
||||
# Test connection using psql
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
||||
|
||||
# Show network configuration
|
||||
echo "Network configuration:"
|
||||
ip addr show
|
||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||
|
||||
- name: Test Docker Image with Health Check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🧪 Testing if the Docker image starts correctly..."
|
||||
|
||||
# Add extra docker run args to support host.docker.internal on Linux
|
||||
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
|
||||
|
||||
# Start the container with host.docker.internal pointing to the host
|
||||
docker run --name formbricks-test \
|
||||
$DOCKER_RUN_ARGS \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||
-d formbricks-test:${{ github.sha }}
|
||||
|
||||
# Give it more time to start up
|
||||
echo "Waiting 45 seconds for application to start..."
|
||||
sleep 45
|
||||
|
||||
# Check if the container is running
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
||||
echo "❌ Container failed to start properly!"
|
||||
docker logs formbricks-test
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Container started successfully!"
|
||||
fi
|
||||
|
||||
# Try connecting to PostgreSQL from inside the container
|
||||
echo "Testing PostgreSQL connection from inside container..."
|
||||
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
||||
|
||||
# Try to access the health endpoint
|
||||
echo "🏥 Testing /health endpoint..."
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
HEALTH_CHECK_SUCCESS=false
|
||||
|
||||
set +e # Disable exit on error to allow for retries
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
# Show container logs before each attempt to help debugging
|
||||
if [ $RETRY_COUNT -gt 1 ]; then
|
||||
echo "📋 Current container logs:"
|
||||
docker logs --tail 20 formbricks-test
|
||||
fi
|
||||
|
||||
# Get detailed curl output for debugging
|
||||
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
||||
CURL_EXIT_CODE=$?
|
||||
|
||||
echo "Curl exit code: $CURL_EXIT_CODE"
|
||||
echo "Curl output: $HTTP_OUTPUT"
|
||||
|
||||
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
||||
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
||||
echo "Status code detected: $STATUS_CODE"
|
||||
|
||||
if [ "$STATUS_CODE" = "200" ]; then
|
||||
echo "✅ Health check successful!"
|
||||
HEALTH_CHECK_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
||||
fi
|
||||
else
|
||||
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
||||
fi
|
||||
|
||||
echo "Waiting 15 seconds before next attempt..."
|
||||
sleep 15
|
||||
done
|
||||
|
||||
# Show full container logs for debugging
|
||||
echo "📋 Full container logs:"
|
||||
docker logs formbricks-test
|
||||
|
||||
# Clean up the container
|
||||
echo "🧹 Cleaning up..."
|
||||
docker rm -f formbricks-test
|
||||
|
||||
# Exit with failure if health check did not succeed
|
||||
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✨ Docker validation complete - all checks passed!"
|
||||
8
.github/workflows/sonarqube.yml
vendored
8
.github/workflows/sonarqube.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
permissions:
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
|
||||
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"expo-status-bar": "2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react-native": "0.78.2",
|
||||
"react-native-webview": "13.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -27,7 +27,7 @@ const secondaryNavigation = [
|
||||
|
||||
export function Sidebar(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
|
||||
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
|
||||
<nav
|
||||
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
|
||||
aria-label="Sidebar">
|
||||
@@ -41,7 +41,7 @@ export function Sidebar(): React.JSX.Element {
|
||||
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
|
||||
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@tailwindcss/forms": "0.5.9",
|
||||
"@tailwindcss/postcss": "4.1.3",
|
||||
"lucide-react": "0.486.0",
|
||||
"next": "15.2.4",
|
||||
"postcss": "8.5.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"react-dom": "19.0.0",
|
||||
"tailwindcss": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function AppPage(): React.JSX.Element {
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
|
||||
</p>
|
||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
|
||||
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
|
||||
|
||||
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
|
||||
<p className="mb-1 sm:mb-0 sm:mr-2">You're connected with env:</p>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
||||
@@ -18,22 +18,22 @@
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-a11y": "8.6.11",
|
||||
"@storybook/addon-essentials": "8.6.11",
|
||||
"@storybook/addon-interactions": "8.6.11",
|
||||
"@storybook/addon-links": "8.6.11",
|
||||
"@storybook/addon-onboarding": "8.6.11",
|
||||
"@storybook/blocks": "8.6.11",
|
||||
"@storybook/react": "8.6.11",
|
||||
"@storybook/react-vite": "8.6.11",
|
||||
"@storybook/test": "8.6.11",
|
||||
"@storybook/addon-a11y": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-onboarding": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.0",
|
||||
"@typescript-eslint/parser": "8.29.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.11",
|
||||
"storybook": "8.6.12",
|
||||
"tsup": "8.4.0",
|
||||
"vite": "6.2.4"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
@@ -40,8 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
|
||||
chmod +x /tmp/read-secrets.sh
|
||||
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
@@ -87,31 +85,60 @@ RUN apk add --no-cache curl \
|
||||
|
||||
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
|
||||
|
||||
# Ensure no write permissions are assigned to the copied resources
|
||||
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
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||
RUN chmod -R 755 ./
|
||||
|
||||
COPY --from=installer /app/apps/web/next.config.mjs .
|
||||
RUN chmod 644 ./next.config.mjs
|
||||
|
||||
COPY --from=installer /app/apps/web/package.json .
|
||||
RUN chmod 644 ./package.json
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
RUN chmod -R 755 ./apps/web/.next/static
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
|
||||
RUN chmod -R 755 ./apps/web/public
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
|
||||
RUN chmod -R 755 ./packages/database/migration
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
|
||||
RUN chmod -R 755 ./packages/database/src
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
|
||||
RUN chmod -R 755 ./packages/database/node_modules
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||
RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
|
||||
|
||||
# Copy Prisma-specific generated files
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
RUN chmod -R 755 ./node_modules/@prisma/client
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
RUN chmod -R 755 ./node_modules/.prisma
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
|
||||
COPY /docker/cronjobs /app/docker/cronjobs
|
||||
RUN chmod 644 ./prisma_version.txt
|
||||
|
||||
COPY /docker/cronjobs /app/docker/cronjobs
|
||||
RUN chmod -R 755 /app/docker/cronjobs
|
||||
|
||||
# Copy required dependencies
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g tsx typescript prisma pino-pretty
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membershipRole}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isLicenseActive={active}
|
||||
|
||||
@@ -63,6 +63,7 @@ interface NavigationProps {
|
||||
projects: TProject[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
@@ -79,6 +80,7 @@ export const MainNavigation = ({
|
||||
isFormbricksCloud,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isDevelopment,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -296,7 +298,7 @@ export const MainNavigation = ({
|
||||
|
||||
<div>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// PosthogIdentify.test.tsx
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
|
||||
|
||||
export default APIKeysLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
|
||||
|
||||
export default APIKeysPage;
|
||||
@@ -0,0 +1,6 @@
|
||||
import Loading from "@/modules/organization/settings/api-keys/loading";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
|
||||
export default function LoadingPage() {
|
||||
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||
|
||||
export default APIKeysPage;
|
||||
@@ -54,6 +54,12 @@ export const OrganizationSettingsNavbar = ({
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
current: pathname?.includes("/enterprise"),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environmentId}/settings/api-keys`,
|
||||
current: pathname?.includes("/api-keys"),
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -33,12 +33,16 @@ vi.mock("@formbricks/lib/constants", () => ({
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-ai",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
@@ -13,6 +14,11 @@ const AppLayout = async ({ children }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
// If user account is deactivated, log them out instead of rendering the app
|
||||
if (user?.isActive === false) {
|
||||
return <ClientLogout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
|
||||
178
apps/web/app/api/v1/auth.test.ts
Normal file
178
apps/web/app/api/v1/auth.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getApiKeyWithPermissions", () => {
|
||||
it("should return API key data with permissions when valid key is provided", async () => {
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
hashedKey: "hashed-key",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage" as const,
|
||||
environment: { id: "env-1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await getApiKeyWithPermissions("test-api-key");
|
||||
|
||||
expect(result).toEqual(mockApiKeyData);
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "api-key-id" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when API key is not found", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getApiKeyWithPermissions("invalid-key");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPermission", () => {
|
||||
const permissions: TAPIKeyEnvironmentPermission[] = [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
{
|
||||
environmentId: "env-2",
|
||||
permission: "write",
|
||||
environmentType: "production",
|
||||
projectId: "project-2",
|
||||
projectName: "Project 2",
|
||||
},
|
||||
{
|
||||
environmentId: "env-3",
|
||||
permission: "read",
|
||||
environmentType: "development",
|
||||
projectId: "project-3",
|
||||
projectName: "Project 3",
|
||||
},
|
||||
];
|
||||
|
||||
it("should return true for manage permission with any method", () => {
|
||||
expect(hasPermission(permissions, "env-1", "GET")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-1", "POST")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle write permission correctly", () => {
|
||||
expect(hasPermission(permissions, "env-2", "GET")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-2", "POST")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle read permission correctly", () => {
|
||||
expect(hasPermission(permissions, "env-3", "GET")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-3", "POST")).toBe(false);
|
||||
expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-existent environment", () => {
|
||||
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
it("should return authentication data for valid API key", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
hashedKey: "hashed-key",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage" as const,
|
||||
environment: {
|
||||
id: "env-1",
|
||||
projectId: "project-1",
|
||||
project: { name: "Project 1" },
|
||||
type: "development",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when no API key is provided", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when API key is invalid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,38 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
if (!apiKey) return null;
|
||||
|
||||
// Get API key with permissions
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
if (!apiKeyData) return null;
|
||||
|
||||
// In the route handlers, we'll do more specific permission checks
|
||||
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||
if (environmentIds.length === 0) return null;
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
environmentId: env.environmentId,
|
||||
environmentType: env.environment.type,
|
||||
permission: env.permission,
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
};
|
||||
|
||||
return authentication;
|
||||
};
|
||||
|
||||
export const handleErrorResponse = (error: any): Response => {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { getHash } from "@formbricks/lib/crypto";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
|
||||
const hashedKey = getHash(apiKey);
|
||||
return cache(
|
||||
async () => {
|
||||
validateInputs([apiKey, ZString]);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InvalidInputError("API key cannot be null or undefined.");
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
throw new ResourceNotFoundError("apiKey", apiKey);
|
||||
}
|
||||
|
||||
return apiKeyData.environmentId;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
@@ -8,15 +9,20 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
const fetchAndAuthorizeActionClass = async (
|
||||
authentication: TAuthenticationApiKey,
|
||||
actionClassId: string
|
||||
actionClassId: string,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
): Promise<TActionClass | null> => {
|
||||
// Get the action class
|
||||
const actionClass = await getActionClass(actionClassId);
|
||||
if (!actionClass) {
|
||||
return null;
|
||||
}
|
||||
if (actionClass.environmentId !== authentication.environmentId) {
|
||||
|
||||
// Check if API key has permission to access this environment with appropriate permissions
|
||||
if (!hasPermission(authentication.environmentPermissions, actionClass.environmentId, method)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return actionClass;
|
||||
};
|
||||
|
||||
@@ -28,7 +34,7 @@ export const GET = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
|
||||
if (actionClass) {
|
||||
return responses.successResponse(actionClass);
|
||||
}
|
||||
@@ -46,7 +52,7 @@ export const PUT = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
|
||||
if (!actionClass) {
|
||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||
}
|
||||
@@ -88,7 +94,7 @@ export const DELETE = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
|
||||
if (!actionClass) {
|
||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getActionClasses } from "./action-classes";
|
||||
|
||||
// Mock the prisma client
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
actionClass: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getActionClasses", () => {
|
||||
const mockEnvironmentIds = ["env1", "env2"];
|
||||
const mockActionClasses = [
|
||||
{
|
||||
id: "action1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action 1",
|
||||
description: "Test Description 1",
|
||||
type: "click",
|
||||
key: "test-key-1",
|
||||
noCodeConfig: {},
|
||||
environmentId: "env1",
|
||||
},
|
||||
{
|
||||
id: "action2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action 2",
|
||||
description: "Test Description 2",
|
||||
type: "pageview",
|
||||
key: "test-key-2",
|
||||
noCodeConfig: {},
|
||||
environmentId: "env2",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should successfully fetch action classes for given environment IDs", async () => {
|
||||
// Mock the prisma findMany response
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
|
||||
|
||||
const result = await getActionClasses(mockEnvironmentIds);
|
||||
|
||||
expect(result).toEqual(mockActionClasses);
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: { in: mockEnvironmentIds },
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw DatabaseError when prisma query fails", async () => {
|
||||
// Mock the prisma findMany to throw an error
|
||||
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("should handle empty environment IDs array", async () => {
|
||||
// Mock the prisma findMany response
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getActionClasses([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: { in: [] },
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
const selectActionClass = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
environmentId: true,
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(
|
||||
async (environmentIds: string[]): Promise<TActionClass[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()]);
|
||||
|
||||
try {
|
||||
return await prisma.actionClass.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
select: selectActionClass,
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
|
||||
}
|
||||
},
|
||||
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,16 +1,24 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getActionClasses } from "./lib/action-classes";
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClasses: TActionClass[] = await getActionClasses(authentication.environmentId!);
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const actionClasses = await getActionClasses(environmentIds);
|
||||
|
||||
return responses.successResponse(actionClasses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -35,6 +43,12 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
|
||||
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
||||
|
||||
const environmentId = actionClassInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
@@ -43,10 +57,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
|
||||
const actionClass: TActionClass = await createActionClass(
|
||||
authentication.environmentId!,
|
||||
inputValidation.data
|
||||
);
|
||||
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
|
||||
return responses.successResponse(actionClass);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -12,29 +12,56 @@ export const GET = async () => {
|
||||
hashedKey: hashApiKey(apiKey),
|
||||
},
|
||||
select: {
|
||||
environment: {
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
type: true,
|
||||
project: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
widgetSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appSetupCompleted: true,
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return new Response("Not authenticated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return Response.json(apiKeyData.environment);
|
||||
|
||||
if (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
apiKeyData.apiKeyEnvironments[0].permission === "manage"
|
||||
) {
|
||||
return Response.json({
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.id,
|
||||
type: apiKeyData.apiKeyEnvironments[0].environment.type,
|
||||
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
|
||||
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
|
||||
widgetSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.widgetSetupCompleted,
|
||||
project: {
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
|
||||
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return new Response("You can't use this method with this API key", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const sessionUser = await getSessionUser();
|
||||
if (!sessionUser) {
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
|
||||
const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise<TResponse> => {
|
||||
async function fetchAndAuthorizeResponse(
|
||||
responseId: string,
|
||||
authentication: any,
|
||||
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||
) {
|
||||
const response = await getResponse(responseId);
|
||||
if (!response || !(await canUserAccessResponse(authentication, response))) {
|
||||
throw new Error("Unauthorized");
|
||||
if (!response) {
|
||||
return { error: responses.notFoundResponse("Response", responseId) };
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const canUserAccessResponse = async (authentication: any, response: TResponse): Promise<boolean> => {
|
||||
const survey = await getSurvey(response.surveyId);
|
||||
if (!survey) return false;
|
||||
|
||||
if (authentication.type === "session") {
|
||||
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
|
||||
} else if (authentication.type === "apiKey") {
|
||||
return survey.environmentId === authentication.environmentId;
|
||||
} else {
|
||||
throw Error("Unknown authentication type");
|
||||
if (!survey) {
|
||||
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
|
||||
return { error: responses.unauthorizedResponse() };
|
||||
}
|
||||
|
||||
return { response };
|
||||
}
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
@@ -36,11 +37,11 @@ export const GET = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const response = await fetchAndValidateResponse(authentication, params.responseId);
|
||||
if (response) {
|
||||
return responses.successResponse(response);
|
||||
}
|
||||
return responses.notFoundResponse("Response", params.responseId);
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
|
||||
if (result.error) return result.error;
|
||||
|
||||
return responses.successResponse(result.response);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
@@ -54,10 +55,10 @@ export const DELETE = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const response = await fetchAndValidateResponse(authentication, params.responseId);
|
||||
if (!response) {
|
||||
return responses.notFoundResponse("Response", params.responseId);
|
||||
}
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
|
||||
if (result.error) return result.error;
|
||||
|
||||
const deletedResponse = await deleteResponse(params.responseId);
|
||||
return responses.successResponse(deletedResponse);
|
||||
} catch (error) {
|
||||
@@ -73,7 +74,10 @@ export const PUT = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
await fetchAndValidateResponse(authentication, params.responseId);
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
|
||||
if (result.error) return result.error;
|
||||
|
||||
let responseUpdate;
|
||||
try {
|
||||
responseUpdate = await request.json();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
@@ -8,11 +10,13 @@ import {
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { getResponseContact } from "@formbricks/lib/response/service";
|
||||
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
@@ -25,6 +29,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
endingId: true,
|
||||
data: true,
|
||||
meta: true,
|
||||
ttc: true,
|
||||
@@ -193,3 +198,53 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponsesByEnvironmentIds = reactCache(
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
try {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
survey: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environmentIds.map(
|
||||
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
|
||||
),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
|
||||
import { getResponses } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse } from "./lib/response";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
@@ -18,14 +19,26 @@ export const GET = async (request: NextRequest) => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
let environmentResponses: TResponse[] = [];
|
||||
let allResponses: TResponse[] = [];
|
||||
|
||||
if (surveyId) {
|
||||
environmentResponses = await getResponses(surveyId, limit, offset);
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId, true);
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
const surveyResponses = await getResponses(surveyId, limit, offset);
|
||||
allResponses.push(...surveyResponses);
|
||||
} else {
|
||||
environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset);
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
return responses.successResponse(environmentResponses);
|
||||
return responses.successResponse(allResponses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
@@ -39,8 +52,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
|
||||
let jsonInput;
|
||||
|
||||
try {
|
||||
@@ -50,9 +61,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
// add environmentId to response
|
||||
jsonInput.environmentId = environmentId;
|
||||
|
||||
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -65,6 +73,12 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
|
||||
const responseInput = inputValidation.data;
|
||||
|
||||
const environmentId = responseInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInput.surveyId);
|
||||
if (!survey) {
|
||||
|
||||
@@ -3,21 +3,28 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
||||
|
||||
const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise<TSurvey | null> => {
|
||||
const fetchAndAuthorizeSurvey = async (
|
||||
surveyId: string,
|
||||
authentication: TAuthenticationApiKey,
|
||||
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||
) => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return null;
|
||||
return { error: responses.notFoundResponse("Survey", surveyId) };
|
||||
}
|
||||
if (survey.environmentId !== authentication.environmentId) {
|
||||
throw new Error("Unauthorized");
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
|
||||
return { error: responses.unauthorizedResponse() };
|
||||
}
|
||||
return survey;
|
||||
|
||||
return { survey };
|
||||
};
|
||||
|
||||
export const GET = async (
|
||||
@@ -28,11 +35,9 @@ export const GET = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
||||
if (survey) {
|
||||
return responses.successResponse(survey);
|
||||
}
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
|
||||
if (result.error) return result.error;
|
||||
return responses.successResponse(result.survey);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
@@ -46,10 +51,8 @@ export const DELETE = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
|
||||
if (result.error) return result.error;
|
||||
const deletedSurvey = await deleteSurvey(params.surveyId);
|
||||
return responses.successResponse(deletedSurvey);
|
||||
} catch (error) {
|
||||
@@ -65,13 +68,10 @@ export const PUT = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
|
||||
if (result.error) return result.error;
|
||||
|
||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
|
||||
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("Organization", null);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const PUT = async (
|
||||
}
|
||||
|
||||
const inputValidation = ZSurveyUpdateInput.safeParse({
|
||||
...survey,
|
||||
...result.survey,
|
||||
...surveyUpdate,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
@@ -17,8 +18,8 @@ export const GET = async (
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
if (survey.environmentId !== authentication.environmentId) {
|
||||
throw new Error("Unauthorized");
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!survey.singleUse || !survey.singleUse.enabled) {
|
||||
|
||||
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal file
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { selectSurvey } from "@formbricks/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting surveys");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -2,12 +2,14 @@ import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { createSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
try {
|
||||
@@ -18,7 +20,11 @@ export const GET = async (request: Request) => {
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const surveys = await getSurveys(authentication.environmentId!, limit, offset);
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
return responses.successResponse(surveys);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -33,11 +39,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("Organization", null);
|
||||
}
|
||||
|
||||
let surveyInput;
|
||||
try {
|
||||
surveyInput = await request.json();
|
||||
@@ -45,8 +46,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
|
||||
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
@@ -56,8 +56,18 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
const surveyData = { ...inputValidation.data, environmentId: undefined };
|
||||
const environmentId = inputValidation.data.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("Organization", null);
|
||||
}
|
||||
|
||||
const surveyData = { ...inputValidation.data, environmentId };
|
||||
|
||||
if (surveyData.followUps?.length) {
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
@@ -73,7 +83,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
}
|
||||
}
|
||||
|
||||
const survey = await createSurvey(environmentId, surveyData);
|
||||
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
|
||||
return responses.successResponse(survey);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
||||
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
@@ -21,7 +22,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== environmentId) {
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
return responses.successResponse(webhook);
|
||||
@@ -34,8 +35,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== environmentId) {
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,20 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
|
||||
try {
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
...webhookInput,
|
||||
url: webhookInput.url,
|
||||
name: webhookInput.name,
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
id: webhookInput.environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -37,22 +40,24 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
||||
}
|
||||
|
||||
if (!(error instanceof InvalidInputError)) {
|
||||
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
|
||||
throw new DatabaseError(
|
||||
`Database error when creating webhook for environment ${webhookInput.environmentId}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> =>
|
||||
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
@@ -66,8 +71,8 @@ export const getWebhooks = (environmentId: string, page?: number): Promise<Webho
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getWebhooks-${environmentId}-${page}`],
|
||||
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
|
||||
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)();
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { headers } from "next/headers";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
export const GET = async () => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
export const GET = async (request: Request) => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
// get webhooks from database
|
||||
try {
|
||||
const webhooks = await getWebhooks(environmentId);
|
||||
return Response.json({ data: webhooks });
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const webhooks = await getWebhooks(environmentIds);
|
||||
return responses.successResponse(webhooks);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const webhookInput = await request.json();
|
||||
@@ -50,9 +41,19 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
const environmentId = inputValidation.data.environmentId;
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Environment ID is required");
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
// add webhook to database
|
||||
try {
|
||||
const webhook = await createWebhook(environmentId, inputValidation.data);
|
||||
const webhook = await createWebhook(inputValidation.data);
|
||||
return responses.successResponse(webhook);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({
|
||||
surveyIds: true,
|
||||
triggers: true,
|
||||
url: true,
|
||||
environmentId: true,
|
||||
});
|
||||
|
||||
export type TWebhookInput = z.infer<typeof ZWebhookInput>;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GET } from "@/modules/api/v2/management/roles/route";
|
||||
|
||||
export { GET };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
|
||||
|
||||
export { GET };
|
||||
3
apps/web/app/api/v2/me/route.ts
Normal file
3
apps/web/app/api/v2/me/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/me/route";
|
||||
|
||||
export { GET };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
|
||||
|
||||
export { GET, POST, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
|
||||
|
||||
export { GET, POST, PATCH };
|
||||
3
apps/web/app/api/v2/roles/route.ts
Normal file
3
apps/web/app/api/v2/roles/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/roles/route";
|
||||
|
||||
export { GET };
|
||||
@@ -40,10 +40,6 @@ vi.mock("@/tolgee/server", () => ({
|
||||
getTolgee: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@vercel/speed-insights/next", () => ({
|
||||
SpeedInsights: () => <div data-testid="speed-insights">SpeedInsights</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
|
||||
<div data-testid="ph-provider">
|
||||
@@ -101,10 +97,6 @@ describe("RootLayout", () => {
|
||||
const element = await RootLayout({ children });
|
||||
render(element);
|
||||
|
||||
// log env vercel
|
||||
console.log("vercel", process.env.VERCEL);
|
||||
|
||||
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TolgeeNextProvider } from "@/tolgee/client";
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
import { getTolgee } from "@/tolgee/server";
|
||||
import { TolgeeStaticData } from "@tolgee/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import { SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
@@ -26,7 +25,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang={locale} translate="no">
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
||||
<SentryProvider sentryDsn={SENTRY_DSN}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SentryProvider } from "./SentryProvider";
|
||||
|
||||
vi.mock("@sentry/nextjs", async () => {
|
||||
|
||||
18
apps/web/lib/cache/api-key.ts
vendored
18
apps/web/lib/cache/api-key.ts
vendored
@@ -2,8 +2,8 @@ import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
hashedKey?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const apiKeyCache = {
|
||||
@@ -11,24 +11,24 @@ export const apiKeyCache = {
|
||||
byId(id: string) {
|
||||
return `apiKeys-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-apiKeys`;
|
||||
},
|
||||
byHashedKey(hashedKey: string) {
|
||||
return `apiKeys-${hashedKey}-apiKey`;
|
||||
},
|
||||
byOrganizationId(organizationId: string) {
|
||||
return `organizations-${organizationId}-apiKeys`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId, hashedKey }: RevalidateProps): void {
|
||||
revalidate({ id, hashedKey, organizationId }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (hashedKey) {
|
||||
revalidateTag(this.tag.byHashedKey(hashedKey));
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
revalidateTag(this.tag.byOrganizationId(organizationId));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => {
|
||||
throw new ResourceNotFoundError("apiKey", apiKeyId);
|
||||
}
|
||||
|
||||
return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId);
|
||||
return apiKeyFromServer.organizationId;
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromInviteId = async (inviteId: string) => {
|
||||
@@ -240,15 +240,6 @@ export const getProjectIdFromSegmentId = async (segmentId: string) => {
|
||||
return await getProjectIdFromEnvironmentId(segment.environmentId);
|
||||
};
|
||||
|
||||
export const getProjectIdFromApiKeyId = async (apiKeyId: string) => {
|
||||
const apiKey = await getApiKey(apiKeyId);
|
||||
if (!apiKey) {
|
||||
throw new ResourceNotFoundError("apiKey", apiKeyId);
|
||||
}
|
||||
|
||||
return await getProjectIdFromEnvironmentId(apiKey.environmentId);
|
||||
};
|
||||
|
||||
export const getProjectIdFromActionClassId = async (actionClassId: string) => {
|
||||
const actionClass = await getActionClass(actionClassId);
|
||||
if (!actionClass) {
|
||||
|
||||
@@ -51,7 +51,7 @@ export const getActionClass = reactCache(
|
||||
);
|
||||
|
||||
export const getApiKey = reactCache(
|
||||
async (apiKeyId: string): Promise<{ environmentId: string } | null> =>
|
||||
async (apiKeyId: string): Promise<{ organizationId: string } | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([apiKeyId, ZString]);
|
||||
@@ -66,7 +66,7 @@ export const getApiKey = reactCache(
|
||||
id: apiKeyId,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
34
apps/web/modules/api/v2/auth/authenticate-request.ts
Normal file
34
apps/web/modules/api/v2/auth/authenticate-request.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: Request
|
||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return err({ type: "unauthorized" });
|
||||
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
|
||||
if (!apiKeyData) return err({ type: "unauthorized" });
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
environmentId: env.environmentId,
|
||||
environmentType: env.environment.type,
|
||||
permission: env.permission,
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
};
|
||||
return ok(authentication);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { 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";
|
||||
114
apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts
Normal file
114
apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authenticateRequest } from "../authenticate-request";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
it("should return authentication data if apiKey is valid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
hashedKey: "hashed-api-key",
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-id-1",
|
||||
permission: "manage",
|
||||
environment: {
|
||||
id: "env-id-1",
|
||||
projectId: "project-id-1",
|
||||
type: "development",
|
||||
project: { name: "Project 1" },
|
||||
},
|
||||
},
|
||||
{
|
||||
environmentId: "env-id-2",
|
||||
permission: "read",
|
||||
environment: {
|
||||
id: "env-id-2",
|
||||
projectId: "project-id-2",
|
||||
type: "production",
|
||||
project: { name: "Project 2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-id-1",
|
||||
permission: "manage",
|
||||
environmentType: "development",
|
||||
projectId: "project-id-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
{
|
||||
environmentId: "env-id-2",
|
||||
permission: "read",
|
||||
environmentType: "production",
|
||||
projectId: "project-id-2",
|
||||
projectName: "Project 2",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized error if apiKey is not found", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized error if apiKey is missing", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -122,9 +122,11 @@ const notFoundResponse = ({
|
||||
const conflictResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
details = [],
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
details?: ApiErrorDetails;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
@@ -136,6 +138,7 @@ const conflictResponse = ({
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -232,7 +235,7 @@ const internalServerErrorResponse = ({
|
||||
const successResponse = ({
|
||||
data,
|
||||
meta,
|
||||
cors = false,
|
||||
cors = true,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
data: Object;
|
||||
|
||||
@@ -85,13 +85,15 @@ describe("API Responses", () => {
|
||||
|
||||
describe("conflictResponse", () => {
|
||||
test("return a 409 response", async () => {
|
||||
const res = responses.conflictResponse();
|
||||
const details = [{ field: "resource", issue: "already exists" }];
|
||||
const res = responses.conflictResponse({ details });
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
|
||||
case "not_found":
|
||||
return responses.notFoundResponse({ details: err.details });
|
||||
case "conflict":
|
||||
return responses.conflictResponse();
|
||||
return responses.conflictResponse({ details: err.details });
|
||||
case "unprocessable_entity":
|
||||
return responses.unprocessableEntityResponse({ details: err.details });
|
||||
case "too_many_requests":
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: Request
|
||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentIdResult.ok) {
|
||||
return err(environmentIdResult.error);
|
||||
}
|
||||
const environmentId = environmentIdResult.data;
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
if (environmentId) {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId,
|
||||
hashedApiKey,
|
||||
};
|
||||
return ok(authentication);
|
||||
}
|
||||
return err({
|
||||
type: "forbidden",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
type: "unauthorized",
|
||||
});
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const checkAuthorization = ({
|
||||
authentication,
|
||||
environmentId,
|
||||
}: {
|
||||
authentication: TAuthenticationApiKey;
|
||||
environmentId: string;
|
||||
}): Result<void, ApiErrorResponseV2> => {
|
||||
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
|
||||
return err({
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { authenticateRequest } from "../authenticate-request";
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/api-key", () => ({
|
||||
getEnvironmentIdFromApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
it("should return authentication data if apiKey is valid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id"));
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should return forbidden error if environmentId is not found", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" }));
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return forbidden error if environmentId is empty", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok(""));
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized error if apiKey is missing", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { checkAuthorization } from "../check-authorization";
|
||||
|
||||
describe("checkAuthorization", () => {
|
||||
it("should return ok if authentication is valid", () => {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
};
|
||||
const result = checkAuthorization({ authentication, environmentId: "env-id" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should return unauthorized error if environmentId does not match", () => {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
};
|
||||
const result = checkAuthorization({ authentication, environmentId: "different-env-id" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string) => {
|
||||
const hashedKey = hashApiKey(apiKey);
|
||||
return cache(
|
||||
async (): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
if (!apiKey) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: [{ field: "apiKey", issue: "API key cannot be null or undefined." }],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return err({ type: "not_found", details: [{ field: "apiKey", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(apiKeyData.environmentId);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "apiKey", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`management-api-getEnvironmentIdFromApiKey-${hashedKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { apiKey, environmentId } from "./__mocks__/api-key.mock";
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn((input: string) => `hashed-${input}`),
|
||||
}));
|
||||
|
||||
describe("getEnvironmentIdFromApiKey", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns a bad_request error if apiKey is empty", async () => {
|
||||
const result = await getEnvironmentIdFromApiKey("");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("bad_request");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "apiKey", issue: "API key cannot be null or undefined." },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a not_found error when no apiKey record is found in the database", async () => {
|
||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: `hashed-${apiKey}` },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("not_found");
|
||||
expect(result.error.details).toEqual([{ field: "apiKey", issue: "not found" }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ok with environmentId when a valid apiKey record is found", async () => {
|
||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ environmentId });
|
||||
|
||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: `hashed-${apiKey}` },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe(environmentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error when an exception occurs during the database lookup", async () => {
|
||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(new Error("Database failure"));
|
||||
|
||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: `hashed-${apiKey}` },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([{ field: "apiKey", issue: "Database failure" }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,12 @@ export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||
return { limit, skip, sortBy, order, startDate, endDate };
|
||||
}
|
||||
|
||||
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
|
||||
type HasFindMany =
|
||||
| Prisma.WebhookFindManyArgs
|
||||
| Prisma.ResponseFindManyArgs
|
||||
| Prisma.TeamFindManyArgs
|
||||
| Prisma.ProjectTeamFindManyArgs
|
||||
| Prisma.UserFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
@@ -11,7 +11,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Gets a response from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
id: ZResponseIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Responses"],
|
||||
@@ -34,7 +34,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
id: ZResponseIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
@@ -56,7 +56,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
id: ZResponseIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
|
||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
|
||||
import { responseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Response } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
@@ -98,7 +98,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
|
||||
|
||||
export const updateResponse = async (
|
||||
responseId: string,
|
||||
responseInput: z.infer<typeof responseUpdateSchema>
|
||||
responseInput: z.infer<typeof ZResponseUpdateSchema>
|
||||
): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedResponse = await prisma.response.update({
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteResponse,
|
||||
getResponse,
|
||||
updateResponse,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { responseIdSchema, responseUpdateSchema } from "./types/responses";
|
||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||
|
||||
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -33,13 +33,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await getResponse(params.responseId);
|
||||
@@ -55,7 +52,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -73,13 +70,10 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await deleteResponse(params.responseId);
|
||||
@@ -97,8 +91,8 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
request,
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
body: responseUpdateSchema,
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
body: ZResponseUpdateSchema,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { body, params } = parsedInput;
|
||||
@@ -115,13 +109,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await updateResponse(params.responseId, body);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const responseIdSchema = z
|
||||
export const ZResponseIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
@@ -16,7 +16,7 @@ export const responseIdSchema = z
|
||||
},
|
||||
});
|
||||
|
||||
export const responseUpdateSchema = ZResponse.omit({
|
||||
export const ZResponseUpdateSchema = ZResponse.omit({
|
||||
id: true,
|
||||
surveyId: true,
|
||||
}).openapi({
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
@@ -14,7 +13,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
summary: "Get responses",
|
||||
description: "Gets responses from the database.",
|
||||
requestParams: {
|
||||
query: ZGetResponsesFilter.sourceType().required(),
|
||||
query: ZGetResponsesFilter.sourceType(),
|
||||
},
|
||||
tags: ["Management API > Responses"],
|
||||
responses: {
|
||||
@@ -22,7 +21,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Responses retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
|
||||
schema: responseWithMetaSchema(makePartialSchema(ZResponse)),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -130,16 +130,18 @@ export const createResponse = async (
|
||||
};
|
||||
|
||||
export const getResponses = async (
|
||||
environmentId: string,
|
||||
environmentIds: string[],
|
||||
params: TGetResponsesFilter
|
||||
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const query = getResponsesQuery(environmentIds, params);
|
||||
|
||||
const [responses, count] = await prisma.$transaction([
|
||||
prisma.response.findMany({
|
||||
...getResponsesQuery(environmentId, params),
|
||||
...query,
|
||||
}),
|
||||
prisma.response.count({
|
||||
where: getResponsesQuery(environmentId, params).where,
|
||||
where: query.where,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
|
||||
describe("getResponsesQuery", () => {
|
||||
it("adds surveyId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
|
||||
const result = getResponsesQuery(["env-id"], { surveyId: "survey123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.surveyId).toBe("survey123");
|
||||
});
|
||||
|
||||
it("adds contactId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
|
||||
const result = getResponsesQuery(["env-id"], { contactId: "contact123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.contactId).toBe("contact123");
|
||||
});
|
||||
|
||||
@@ -24,12 +24,12 @@ describe("getResponsesQuery", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
|
||||
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
|
||||
|
||||
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
|
||||
const result = getResponsesQuery(["env-id"], { surveyId: "test" } as TGetResponsesFilter);
|
||||
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
|
||||
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Prisma.ResponseFindManyArgs>({
|
||||
where: {
|
||||
survey: { environmentId: "env-id" },
|
||||
survey: { environmentId: { in: ["env-id"] } },
|
||||
surveyId: "test",
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2,11 +2,11 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/manag
|
||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
|
||||
export const getResponsesQuery = (environmentIds: string[], params?: TGetResponsesFilter) => {
|
||||
let query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
survey: {
|
||||
environmentId,
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Response } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { createResponse, getResponses } from "./lib/response";
|
||||
|
||||
@@ -23,15 +24,20 @@ export const GET = async (request: NextRequest) =>
|
||||
});
|
||||
}
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const res = await getResponses(environmentId, query);
|
||||
const environmentResponses: Response[] = [];
|
||||
const res = await getResponses(environmentIds, query);
|
||||
|
||||
if (res.ok) {
|
||||
return responses.successResponse(res.data);
|
||||
if (!res.ok) {
|
||||
return handleApiError(request, res.error);
|
||||
}
|
||||
|
||||
return handleApiError(request, res.error);
|
||||
environmentResponses.push(...res.data.data);
|
||||
|
||||
return responses.successResponse({ data: environmentResponses });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,13 +65,10 @@ export const POST = async (request: Request) =>
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
|
||||
@@ -78,6 +81,6 @@ export const POST = async (request: Request) =>
|
||||
return handleApiError(request, createResponseResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: createResponseResult.data, cors: true });
|
||||
return responses.successResponse({ data: createResponseResult.data });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponse } from "@/modules/api/v2/types/api-success";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getRoles = async (): Promise<Result<ApiResponse<string[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
// We use a raw query to get all the roles because we can't list enum options with prisma
|
||||
const results = await prisma.$queryRaw<{ unnest: string }[]>`
|
||||
SELECT unnest(enum_range(NULL::"OrganizationRole"));
|
||||
`;
|
||||
|
||||
if (!results) {
|
||||
// We set internal_server_error because it's an enum and we should always have the roles
|
||||
return err({ type: "internal_server_error", details: [{ field: "roles", issue: "not found" }] });
|
||||
}
|
||||
|
||||
const roles = results.map((row) => row.unnest);
|
||||
|
||||
return ok({
|
||||
data: roles,
|
||||
});
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "roles", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getRoles } from "../roles";
|
||||
|
||||
// Mock prisma with a $queryRaw function
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getRoles", () => {
|
||||
it("returns roles on success", async () => {
|
||||
(prisma.$queryRaw as any).mockResolvedValueOnce([{ unnest: "ADMIN" }, { unnest: "MEMBER" }]);
|
||||
|
||||
const result = await getRoles();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(["ADMIN", "MEMBER"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error if no results are found", async () => {
|
||||
(prisma.$queryRaw as any).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getRoles();
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error on exception", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error("Test DB error"));
|
||||
|
||||
const result = await getRoles();
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
|
||||
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
|
||||
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
@@ -43,13 +43,10 @@ export const GET = async (
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const surveyResult = await getSurvey(params.surveyId);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
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 getContactAttributeKeys = reactCache((environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const contactAttributeKeys = await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
const keys = contactAttributeKeys.map((key) => key.key);
|
||||
return ok(keys);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "contact attribute keys", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`getContactAttributeKeys-contact-links-${environmentId}`],
|
||||
{
|
||||
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
|
||||
import { getSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment";
|
||||
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys";
|
||||
import { TContactWithAttributes } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getContactsInSegment = reactCache(
|
||||
(surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<ApiResponseWithMeta<TContactWithAttributes[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const surveyResult = await getSurvey(surveyId);
|
||||
if (!surveyResult.ok) {
|
||||
return err(surveyResult.error);
|
||||
}
|
||||
|
||||
const survey = surveyResult.data;
|
||||
|
||||
if (survey.type !== "link" || survey.status !== "inProgress") {
|
||||
logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress");
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
return err(error);
|
||||
}
|
||||
|
||||
const segmentResult = await getSegment(segmentId);
|
||||
if (!segmentResult.ok) {
|
||||
return err(segmentResult.error);
|
||||
}
|
||||
|
||||
const segment = segmentResult.data;
|
||||
|
||||
if (survey.environmentId !== segment.environmentId) {
|
||||
logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment");
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "bad_request",
|
||||
details: [{ field: "segmentId", issue: "Environment mismatch" }],
|
||||
};
|
||||
return err(error);
|
||||
}
|
||||
|
||||
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
|
||||
segment.id,
|
||||
segment.filters,
|
||||
segment.environmentId
|
||||
);
|
||||
|
||||
if (!segmentFilterToPrismaQueryResult.ok) {
|
||||
return err(segmentFilterToPrismaQueryResult.error);
|
||||
}
|
||||
|
||||
const { whereClause } = segmentFilterToPrismaQueryResult.data;
|
||||
|
||||
const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId);
|
||||
if (!contactAttributeKeysResult.ok) {
|
||||
return err(contactAttributeKeysResult.error);
|
||||
}
|
||||
|
||||
const allAttributeKeys = contactAttributeKeysResult.data;
|
||||
|
||||
const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim());
|
||||
const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field));
|
||||
|
||||
const allowedAttributes = attributesToInclude.slice(0, 20);
|
||||
|
||||
const [totalContacts, contacts] = await prisma.$transaction([
|
||||
prisma.contact.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
|
||||
prisma.contact.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: allowedAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
skip: skip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const contactsWithAttributes = contacts.map((contact) => {
|
||||
const attributes = contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
return {
|
||||
contactId: contact.id,
|
||||
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return ok({
|
||||
data: contactsWithAttributes,
|
||||
meta: {
|
||||
total: totalContacts,
|
||||
limit: limit,
|
||||
offset: skip,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment");
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
};
|
||||
return err(apiError);
|
||||
}
|
||||
},
|
||||
[`getContactsInSegment-${surveyId}-${segmentId}-${attributeKeys}-${limit}-${skip}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byId(segmentId), surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
ZContactLinkResponse,
|
||||
ZContactLinksBySegmentParams,
|
||||
ZContactLinksBySegmentQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
|
||||
export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactLinksBySegment",
|
||||
summary: "Get survey links for contacts in a segment",
|
||||
description: "Generates personalized survey links for contacts in a segment.",
|
||||
tags: ["Management API > Surveys > Contact Links"],
|
||||
requestParams: {
|
||||
path: ZContactLinksBySegmentParams,
|
||||
query: ZContactLinksBySegmentQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact links generated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZContactLinkResponse))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Segment } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getSegment = reactCache(async (segmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Pick<Segment, "id" | "environmentId" | "filters">, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const segment = await prisma.segment.findUnique({
|
||||
where: { id: segmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(segment);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`contact-link-getSegment-${segmentId}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byId(segmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Survey } 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 { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getSurvey = reactCache(async (surveyId: string) =>
|
||||
cache(
|
||||
async (): Promise<
|
||||
Result<Pick<Survey, "id" | "environmentId" | "type" | "status">, ApiErrorResponseV2>
|
||||
> => {
|
||||
try {
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!survey) {
|
||||
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(survey);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`contact-link-getSurvey-${surveyId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getContactAttributeKeys", () => {
|
||||
const mockEnvironmentId = "mock-env-123";
|
||||
const mockContactAttributeKeys = [{ key: "email" }, { key: "name" }, { key: "userId" }];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("successfully retrieves contact attribute keys", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockContactAttributeKeys);
|
||||
|
||||
const result = await getContactAttributeKeys(mockEnvironmentId);
|
||||
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: mockEnvironmentId },
|
||||
select: { key: true },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(["email", "name", "userId"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles database error gracefully", async () => {
|
||||
const mockError = new Error("Database error");
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(mockError);
|
||||
|
||||
const result = await getContactAttributeKeys(mockEnvironmentId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "contact attribute keys", issue: mockError.message }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { SurveyStatus, SurveyType } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { TBaseFilters } from "@formbricks/types/segment";
|
||||
import { getContactsInSegment } from "../contact";
|
||||
import { getSegment } from "../segment";
|
||||
import { getSurvey } from "../surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../segment", () => ({
|
||||
getSegment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../surveys", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getContactsInSegment", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockSegmentId = "segment-456";
|
||||
const mockLimit = 10;
|
||||
const mockSkip = 0;
|
||||
const mockEnvironmentId = "env-789";
|
||||
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
type: "link" as SurveyType,
|
||||
status: "inProgress" as SurveyStatus,
|
||||
};
|
||||
|
||||
// Define filters as a TBaseFilters array with correct structure
|
||||
const mockFilters: TBaseFilters = [
|
||||
{
|
||||
id: "filter-1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "resource-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
value: "test@example.com",
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockSegment = {
|
||||
id: mockSegmentId,
|
||||
environmentId: mockEnvironmentId,
|
||||
filters: mockFilters,
|
||||
};
|
||||
|
||||
const mockContacts = [
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "another@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Another User" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSurvey,
|
||||
});
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSegment,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([{ key: "email" }, { key: "name" }]);
|
||||
|
||||
vi.mocked(prisma.contact.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return contacts when all operations succeed", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
|
||||
const attributeKeys = "email,name";
|
||||
const result = await getContactsInSegment(
|
||||
mockSurveyId,
|
||||
mockSegmentId,
|
||||
mockLimit,
|
||||
mockSkip,
|
||||
attributeKeys
|
||||
);
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{
|
||||
environmentId: "env-789",
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
},
|
||||
value: { equals: "test@example.com", mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.contact.count).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
});
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["email", "name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: mockLimit,
|
||||
skip: mockSkip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should filter contact attributes when fields parameter is provided", async () => {
|
||||
const filteredMockContacts = [
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [{ attributeKey: { key: "email" }, value: "another@example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([filteredMockContacts.length, filteredMockContacts]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email");
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{
|
||||
environmentId: "env-789",
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
},
|
||||
value: { equals: "test@example.com", mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
|
||||
expect(prisma.contact.count).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
});
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["email"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: mockLimit,
|
||||
skip: mockSkip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle multiple fields when fields parameter has comma-separated values", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email,name");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return no attributes but still return contacts when fields parameter is empty", async () => {
|
||||
const mockContactsWithoutAttributes = mockContacts.map((contact) => ({
|
||||
...contact,
|
||||
attributes: [],
|
||||
}));
|
||||
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([
|
||||
mockContactsWithoutAttributes.length,
|
||||
mockContactsWithoutAttributes,
|
||||
]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: mockContacts.map((contact) => ({
|
||||
contactId: contact.id,
|
||||
})),
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not a link survey", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
...mockSurvey,
|
||||
type: "web" as SurveyType,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not active", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
...mockSurvey,
|
||||
status: "completed" as SurveyStatus,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not found", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: false,
|
||||
error: surveyError,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when segment is not found", async () => {
|
||||
const segmentError: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "segment", issue: "not found" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: false,
|
||||
error: segmentError,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).not.toHaveBeenCalled();
|
||||
expect(prisma.contact.findMany).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(segmentError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey and segment are in different environments", async () => {
|
||||
const mockSegmentWithDifferentEnv = {
|
||||
...mockSegment,
|
||||
environmentId: "different-env",
|
||||
};
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSegmentWithDifferentEnv,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).not.toHaveBeenCalled();
|
||||
expect(prisma.contact.findMany).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
details: [{ field: "segmentId", issue: "Environment mismatch" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when database operation fails", async () => {
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.contact.count).mockRejectedValue(dbError);
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Segment } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { getSegment } from "../segment";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
segment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache/segment", () => ({
|
||||
segmentCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `segment-${id}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getSegment", () => {
|
||||
const mockSegmentId = "segment-123";
|
||||
const mockSegment: Pick<Segment, "id" | "environmentId" | "filters"> = {
|
||||
id: mockSegmentId,
|
||||
environmentId: "env-123",
|
||||
filters: [
|
||||
{
|
||||
id: "filter-123",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "attr_1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
value: "test@example.com",
|
||||
qualifier: { operator: "equals" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return segment data when segment is found", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSegmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockSegment);
|
||||
}
|
||||
|
||||
expect(segmentCache.tag.byId).toHaveBeenCalledWith(mockSegmentId);
|
||||
});
|
||||
|
||||
test("should return not_found error when segment doesn't exist", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSegmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "segment", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error when database throws an error", async () => {
|
||||
const mockError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.segment.findUnique).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "segment", issue: "Database connection failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should use correct cache key", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
|
||||
|
||||
await getSegment(mockSegmentId);
|
||||
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSegment-${mockSegmentId}`], {
|
||||
tags: [`segment-${mockSegmentId}`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSurvey } from "../surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/survey/cache", () => ({
|
||||
surveyCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `survey-${id}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getSurvey", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockEnvironmentId = "env-456";
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return survey data when survey is found", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSurveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockSurvey);
|
||||
}
|
||||
|
||||
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
|
||||
tags: [`survey-${mockSurveyId}`],
|
||||
});
|
||||
});
|
||||
|
||||
test("should return not_found error when survey doesn't exist", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSurveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error when database throws an error", async () => {
|
||||
const mockError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "survey", issue: "Database connection failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should use correct cache key and tags", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
|
||||
|
||||
await getSurvey(mockSurveyId);
|
||||
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
|
||||
tags: [`survey-${mockSurveyId}`],
|
||||
});
|
||||
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
|
||||
import {
|
||||
ZContactLinksBySegmentParams,
|
||||
ZContactLinksBySegmentQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ surveyId: string; segmentId: string }> }
|
||||
) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: ZContactLinksBySegmentParams,
|
||||
query: ZContactLinksBySegmentQuery,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params, query } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return handleApiError(request, {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
// Get contacts based on segment
|
||||
const contactsResult = await getContactsInSegment(
|
||||
params.surveyId,
|
||||
params.segmentId,
|
||||
query?.limit || 10,
|
||||
query?.skip || 0,
|
||||
query?.attributeKeys
|
||||
);
|
||||
|
||||
if (!contactsResult.ok) {
|
||||
return handleApiError(request, contactsResult.error);
|
||||
}
|
||||
|
||||
const { data: contacts, meta } = contactsResult.data;
|
||||
|
||||
// Calculate expiration date based on expirationDays
|
||||
let expiresAt: string | null = null;
|
||||
if (query?.expirationDays) {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
|
||||
expiresAt = expirationDate.toISOString();
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contacts
|
||||
.map((contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(
|
||||
contactId,
|
||||
params.surveyId,
|
||||
query?.expirationDays || undefined
|
||||
);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
logger.error(
|
||||
{ error: surveyUrlResult.error, contactId: contactId, surveyId: params.surveyId },
|
||||
"Failed to generate survey URL for contact"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
contactId,
|
||||
attributes,
|
||||
surveyUrl: surveyUrlResult.data,
|
||||
expiresAt,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return responses.successResponse({
|
||||
data: contactLinks,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZContactLinksBySegmentParams = z.object({
|
||||
surveyId: z.string().cuid2().describe("The ID of the survey"),
|
||||
segmentId: z.string().cuid2().describe("The ID of the segment"),
|
||||
});
|
||||
|
||||
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
|
||||
limit: true,
|
||||
skip: true,
|
||||
}).extend({
|
||||
expirationDays: z.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.nullish()
|
||||
.default(null)
|
||||
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
|
||||
attributeKeys: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated list of contact attribute keys to include in the response. You can have max 20 keys. If not provided, no attributes will be included."
|
||||
)
|
||||
.refine((fields) => {
|
||||
if (!fields) return true;
|
||||
const fieldsArray = fields.split(",");
|
||||
return fieldsArray.length <= 20;
|
||||
}, "You can have max 20 keys."),
|
||||
});
|
||||
|
||||
export type TContactWithAttributes = {
|
||||
contactId: string;
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const ZContactLinkResponse = z.object({
|
||||
contactId: z.string().describe("The ID of the contact"),
|
||||
surveyUrl: z.string().url().describe("Personalized survey link"),
|
||||
expiresAt: z.string().nullable().describe("The date and time the link expires, null if no expiration"),
|
||||
attributes: z.record(z.string(), z.string()).describe("The attributes of the contact"),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
|
||||
import { ZodOpenApiPathsObject } from "zod-openapi";
|
||||
|
||||
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
|
||||
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
|
||||
get: getContactLinksBySegmentEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
@@ -11,7 +11,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Gets a webhook from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: ZWebhookIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Webhooks"],
|
||||
@@ -34,7 +34,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: ZWebhookIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
@@ -56,7 +56,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: ZWebhookIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
mockedPrismaWebhookUpdateReturn,
|
||||
prismaNotFoundError,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -61,7 +61,7 @@ describe("getWebhook", () => {
|
||||
});
|
||||
|
||||
describe("updateWebhook", () => {
|
||||
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof webhookUpdateSchema>;
|
||||
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof ZWebhookUpdateSchema>;
|
||||
|
||||
test("returns ok on successful update", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
@@ -42,7 +42,7 @@ export const getWebhook = async (webhookId: string) =>
|
||||
|
||||
export const updateWebhook = async (
|
||||
webhookId: string,
|
||||
webhookInput: z.infer<typeof webhookUpdateSchema>
|
||||
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
|
||||
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedWebhook = await prisma.webhook.update({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteWebhook,
|
||||
@@ -9,9 +8,10 @@ import {
|
||||
updateWebhook,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook";
|
||||
import {
|
||||
webhookIdSchema,
|
||||
webhookUpdateSchema,
|
||||
ZWebhookIdSchema,
|
||||
ZWebhookUpdateSchema,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -19,7 +19,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
params: z.object({ webhookId: ZWebhookIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -38,13 +38,11 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse(webhook);
|
||||
@@ -55,8 +53,8 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
body: webhookUpdateSchema,
|
||||
params: z.object({ webhookId: ZWebhookIdSchema }),
|
||||
body: ZWebhookUpdateSchema,
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -83,14 +81,11 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
// check webhook environment against the api key environment
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "PUT")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
// check if webhook environment matches the surveys environment
|
||||
@@ -117,7 +112,7 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
params: z.object({ webhookId: ZWebhookIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -136,13 +131,11 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "DELETE")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const deletedWebhook = await deleteWebhook(params.webhookId);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const webhookIdSchema = z
|
||||
export const ZWebhookIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
@@ -16,7 +16,7 @@ export const webhookIdSchema = z
|
||||
},
|
||||
});
|
||||
|
||||
export const webhookUpdateSchema = ZWebhook.omit({
|
||||
export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user