mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 14:10:45 -06:00
Compare commits
34 Commits
fix-user-i
...
v3.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c533f37983 | ||
|
|
ca4f8385e4 | ||
|
|
3eb9aa74ed | ||
|
|
637b51464c | ||
|
|
fd9585a66e | ||
|
|
49ecbcb0c9 | ||
|
|
1132bdd66a | ||
|
|
c7d6ed9ea3 | ||
|
|
782528f169 | ||
|
|
104c78275f | ||
|
|
d9d88f7175 | ||
|
|
bf7e24cf11 | ||
|
|
c8aba01db3 | ||
|
|
a896c7e46e | ||
|
|
8018ec14a2 | ||
|
|
9c3208c860 | ||
|
|
e1063964cf | ||
|
|
38568738cc | ||
|
|
15b8358b14 | ||
|
|
2173cb2610 | ||
|
|
87b925d622 | ||
|
|
885b06cc26 | ||
|
|
adb6a5f41e | ||
|
|
3b815e22e3 | ||
|
|
4d4a5c0e64 | ||
|
|
0e89293974 | ||
|
|
c306911b3a | ||
|
|
4f276f0095 | ||
|
|
81fc97c7e9 | ||
|
|
785c5a59c6 | ||
|
|
25ecfaa883 | ||
|
|
38e2c019fa | ||
|
|
15878a4ac5 | ||
|
|
9802536ded |
@@ -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
|
||||
@@ -155,9 +155,8 @@ STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# Configure Formbricks usage within Formbricks
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
||||
FORMBRICKS_API_HOST=
|
||||
FORMBRICKS_ENVIRONMENT_ID=
|
||||
|
||||
# Oauth credentials for Google sheet integration
|
||||
GOOGLE_SHEETS_CLIENT_ID=
|
||||
|
||||
12
.github/actions/cache-build-web/action.yml
vendored
12
.github/actions/cache-build-web/action.yml
vendored
@@ -8,6 +8,14 @@ on:
|
||||
required: false
|
||||
default: "0"
|
||||
|
||||
inputs:
|
||||
turbo_token:
|
||||
description: "Turborepo token"
|
||||
required: false
|
||||
turbo_team:
|
||||
description: "Turborepo team"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -62,6 +70,8 @@ runs:
|
||||
|
||||
- run: |
|
||||
pnpm build --filter=@formbricks/web...
|
||||
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
TURBO_TOKEN: ${{ inputs.turbo_token }}
|
||||
TURBO_TEAM: ${{ inputs.turbo_team }}
|
||||
|
||||
4
.github/workflows/build-web.yml
vendored
4
.github/workflows/build-web.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Formbricks-web
|
||||
@@ -25,3 +25,5 @@ jobs:
|
||||
id: cache-build-web
|
||||
with:
|
||||
e2e_testing_mode: "0"
|
||||
turbo_token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo_team: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
167
.github/workflows/docker-build-validation.yml
vendored
Normal file
167
.github/workflows/docker-build-validation.yml
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
name: Docker Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
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!"
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -16,6 +16,8 @@ on:
|
||||
|
||||
env:
|
||||
TELEMETRY_DISABLED: 1
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "formbricks",
|
||||
"projectKey": "formbricks_formbricks"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"expo-status-bar": "2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.78.2",
|
||||
"react-native": "0.76.6",
|
||||
"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 pt-5 pb-4">
|
||||
<nav
|
||||
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
|
||||
aria-label="Sidebar">
|
||||
@@ -38,10 +38,10 @@ export function Sidebar(): React.JSX.Element {
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
|
||||
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
|
||||
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
|
||||
)}
|
||||
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>
|
||||
))}
|
||||
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white">
|
||||
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
|
||||
<item.icon className="mr-4 h-6 w-6 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
"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",
|
||||
"tailwindcss": "3.4.16"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwindcss": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -96,10 +96,10 @@ 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="mt-4 rounded-xs" 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>
|
||||
<p className="mb-1 sm:mr-2 sm:mb-0">You're connected with env:</p>
|
||||
<div className="flex items-center">
|
||||
<strong className="w-32 truncate sm:w-auto">
|
||||
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
|
||||
|
||||
@@ -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")],
|
||||
};
|
||||
@@ -27,14 +27,14 @@
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.1",
|
||||
"@typescript-eslint/parser": "8.29.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
"tsup": "8.4.0",
|
||||
"vite": "6.2.4"
|
||||
"vite": "6.2.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,191 +1,120 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import SurveyEditorEnvironmentLayout from "./layout";
|
||||
|
||||
// mock all dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
// Mock sub-components to render identifiable elements
|
||||
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
||||
<div data-testid="EnvironmentIdBaseLayout">
|
||||
{environmentId}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: any) => (
|
||||
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
// Mocks for dependencies
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
environmentIdLayoutChecks: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(() => {
|
||||
return (key: string) => key; // trivial translator returning the key
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock child components rendered by the layout:
|
||||
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
|
||||
FormbricksClient: () => <div data-testid="formbricks-client" />,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
|
||||
PosthogIdentify: () => <div data-testid="posthog-identify" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="mock-toaster" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
|
||||
<div data-testid="dev-environment-banner">{environment?.id || "no-env"}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="mock-response-filter-provider">{children}</div>
|
||||
),
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SurveyEditorEnvironmentLayout", () => {
|
||||
beforeEach(() => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("redirects to /auth/login if there is no session", async () => {
|
||||
// Mock no session
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
it("renders successfully when environment is found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
|
||||
|
||||
const layoutElement = await SurveyEditorEnvironmentLayout({
|
||||
params: { environmentId: "env-123" },
|
||||
children: <div data-testid="child-content">Hello!</div>,
|
||||
const result = await SurveyEditorEnvironmentLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Survey Editor Content</div>,
|
||||
});
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
// No JSX is returned after redirect
|
||||
expect(layoutElement).toBeUndefined();
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
||||
});
|
||||
|
||||
it("throws error if user does not exist in DB", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
|
||||
|
||||
await expect(
|
||||
SurveyEditorEnvironmentLayout({
|
||||
params: { environmentId: "env-123" },
|
||||
children: <div data-testid="child-content">Hello!</div>,
|
||||
})
|
||||
).rejects.toThrow("common.user_not_found");
|
||||
});
|
||||
|
||||
it("throws AuthorizationError if user does not have environment access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
SurveyEditorEnvironmentLayout({
|
||||
params: { environmentId: "env-123" },
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
it("throws if no organization is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
SurveyEditorEnvironmentLayout({
|
||||
params: { environmentId: "env-123" },
|
||||
children: <div data-testid="child-content">Hello from children!</div>,
|
||||
})
|
||||
).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
it("throws if no environment is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
|
||||
it("throws an error when environment is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
SurveyEditorEnvironmentLayout({
|
||||
params: { environmentId: "env-123" },
|
||||
children: <div>Child</div>,
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.environment_not_found");
|
||||
});
|
||||
|
||||
it("renders environment layout if everything is valid", async () => {
|
||||
// Provide all valid data
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env-123",
|
||||
name: "My Test Environment",
|
||||
} as unknown as TEnvironment);
|
||||
|
||||
// Because it's an async server component, we typically wrap in act(...)
|
||||
let layoutElement: React.ReactNode;
|
||||
|
||||
await act(async () => {
|
||||
layoutElement = await SurveyEditorEnvironmentLayout({
|
||||
params: { environmentId: "env-123" },
|
||||
children: <div data-testid="child-content">Hello from children!</div>,
|
||||
});
|
||||
render(layoutElement);
|
||||
it("calls redirect when session is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: undefined as unknown as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
});
|
||||
|
||||
// Now confirm we got the child plus all the mocked sub-components
|
||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
|
||||
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
|
||||
await expect(
|
||||
SurveyEditorEnvironmentLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("Redirect called");
|
||||
});
|
||||
|
||||
it("throws error if user is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
});
|
||||
|
||||
await expect(
|
||||
SurveyEditorEnvironmentLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.user_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,24 @@
|
||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session?.user) {
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError(t("common.not_authorized"));
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(params.environmentId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
@@ -48,23 +26,16 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponseFilterProvider>
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
environmentId={params.environmentId}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
/>
|
||||
<FormbricksClient userId={user.id} email={user.email} />
|
||||
<ToasterClient />
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
</ResponseFilterProvider>
|
||||
</EnvironmentIdBaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { FormbricksClient } from "./FormbricksClient";
|
||||
|
||||
@@ -9,14 +9,6 @@ vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams("foo=bar"),
|
||||
}));
|
||||
|
||||
// Mock the environment variables.
|
||||
vi.mock("@formbricks/lib/env", () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the flag that enables Formbricks.
|
||||
vi.mock("@/app/lib/formbricks", () => ({
|
||||
formbricksEnabled: true,
|
||||
@@ -34,17 +26,21 @@ vi.mock("@formbricks/js", () => ({
|
||||
}));
|
||||
|
||||
describe("FormbricksClient", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
|
||||
const mockSetup = vi.spyOn(formbricks, "setup");
|
||||
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
|
||||
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||
|
||||
render(<FormbricksClient userId="user-123" email="test@example.com" />);
|
||||
render(
|
||||
<FormbricksClient
|
||||
userId="user-123"
|
||||
email="test@example.com"
|
||||
formbricksEnvironmentId="env-test"
|
||||
formbricksApiHost="https://api.test.com"
|
||||
formbricksEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Expect the first effect to call setup and assign the provided user details.
|
||||
expect(mockSetup).toHaveBeenCalledWith({
|
||||
@@ -64,7 +60,15 @@ describe("FormbricksClient", () => {
|
||||
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||
|
||||
render(<FormbricksClient userId="" email="test@example.com" />);
|
||||
render(
|
||||
<FormbricksClient
|
||||
userId=""
|
||||
email="test@example.com"
|
||||
formbricksEnvironmentId="env-test"
|
||||
formbricksApiHost="https://api.test.com"
|
||||
formbricksEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Since userId is falsy, the first effect should not call setup or assign user details.
|
||||
expect(mockSetup).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { formbricksEnabled } from "@/app/lib/formbricks";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
|
||||
export const FormbricksClient = ({ userId, email }: { userId: string; email: string }) => {
|
||||
interface FormbricksClientProps {
|
||||
userId: string;
|
||||
email: string;
|
||||
formbricksEnvironmentId?: string;
|
||||
formbricksApiHost?: string;
|
||||
formbricksEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const FormbricksClient = ({
|
||||
userId,
|
||||
email,
|
||||
formbricksEnvironmentId,
|
||||
formbricksApiHost,
|
||||
formbricksEnabled,
|
||||
}: FormbricksClientProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled && userId) {
|
||||
formbricks.setup({
|
||||
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
environmentId: formbricksEnvironmentId ?? "",
|
||||
appUrl: formbricksApiHost ?? "",
|
||||
});
|
||||
|
||||
formbricks.setUserId(userId);
|
||||
formbricks.setEmail(email);
|
||||
}
|
||||
}, [userId, email]);
|
||||
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled) {
|
||||
formbricks.registerRouteChange();
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
}, [pathname, searchParams, formbricksEnabled]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -263,7 +265,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
@@ -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,250 +1,156 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import EnvLayout from "./layout";
|
||||
|
||||
// mock all the dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
// Mock sub-components to render identifiable elements
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
|
||||
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
||||
<div data-testid="EnvironmentIdBaseLayout">
|
||||
{environmentId}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="ToasterClient" />,
|
||||
}));
|
||||
vi.mock("../../components/FormbricksClient", () => ({
|
||||
FormbricksClient: ({ userId, email }: any) => (
|
||||
<div data-testid="FormbricksClient">
|
||||
{userId}-{email}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
||||
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(() => {
|
||||
return (key: string) => {
|
||||
return key;
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
// Mocks for dependencies
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
environmentIdLayoutChecks: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/project/service", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/lib/aiModels", () => ({
|
||||
llmModel: {},
|
||||
}));
|
||||
|
||||
// mock all the components that are rendered in the layout
|
||||
|
||||
vi.mock("./components/PosthogIdentify", () => ({
|
||||
PosthogIdentify: () => <div data-testid="posthog-identify" />,
|
||||
}));
|
||||
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
|
||||
FormbricksClient: () => <div data-testid="formbricks-client" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="mock-toaster" />,
|
||||
}));
|
||||
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
||||
default: () => <div data-testid="mock-storage-handler" />,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="mock-response-filter-provider">{children}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
|
||||
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="mock-environment-result">{children}</div>
|
||||
),
|
||||
vi.mock("@formbricks/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("EnvLayout", () => {
|
||||
beforeEach(() => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("redirects to /auth/login if there is no session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
// Since it's an async server component, call EnvLayout yourself:
|
||||
const layoutElement = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
children: <div data-testid="child-content">Hello!</div>,
|
||||
it("renders successfully when all dependencies return valid data", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||
id: "member1",
|
||||
} as unknown as TMembership);
|
||||
|
||||
// Because we have no session, we expect a redirect to "/auth/login"
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Content</div>,
|
||||
});
|
||||
render(result);
|
||||
|
||||
// If your code calls redirect() early and returns no JSX,
|
||||
// layoutElement might be undefined or null.
|
||||
expect(layoutElement).toBeUndefined();
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Content");
|
||||
});
|
||||
|
||||
it("redirects to /auth/login if user does not exist in DB", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({
|
||||
user: { id: "user-123" },
|
||||
it("throws error if project is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
|
||||
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
|
||||
|
||||
const layoutElement = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
children: <div data-testid="child-content">Hello!</div>,
|
||||
});
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(layoutElement).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws AuthorizationError if user does not have environment access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({
|
||||
user: { id: "user-123" },
|
||||
});
|
||||
vi.mocked(getUser).mockResolvedValueOnce({
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
} as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
it("throws if no organization is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({
|
||||
user: { id: "user-123" },
|
||||
});
|
||||
vi.mocked(getUser).mockResolvedValueOnce({
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
} as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
children: <div data-testid="child-content">Hello from children!</div>,
|
||||
})
|
||||
).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
it("throws if no project is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({
|
||||
user: { id: "user-123" },
|
||||
});
|
||||
vi.mocked(getUser).mockResolvedValueOnce({
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
} as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||
id: "member1",
|
||||
} as unknown as TMembership);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
children: <div>Child</div>,
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("project_not_found");
|
||||
).rejects.toThrow("common.project_not_found");
|
||||
});
|
||||
|
||||
it("calls notFound if membership is missing", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({
|
||||
user: { id: "user-123" },
|
||||
it("throws error if membership is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getUser).mockResolvedValueOnce({
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
} as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
children: <div>Child</div>,
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("membership_not_found");
|
||||
).rejects.toThrow("common.membership_not_found");
|
||||
});
|
||||
|
||||
it("renders environment layout if everything is valid", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({
|
||||
user: { id: "user-123" },
|
||||
it("calls redirect when session is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: undefined as unknown as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getUser).mockResolvedValueOnce({
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
} as TUser);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||
id: "membership-123",
|
||||
} as unknown as TMembership);
|
||||
|
||||
let layoutElement: React.ReactNode;
|
||||
|
||||
await act(async () => {
|
||||
layoutElement = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
children: <div data-testid="child-content">Hello from children!</div>,
|
||||
});
|
||||
|
||||
// Now render the fully resolved layout
|
||||
render(layoutElement);
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
|
||||
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("Redirect called");
|
||||
});
|
||||
|
||||
it("throws error if user is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
});
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.user_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { FormbricksClient } from "../../components/FormbricksClient";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
import { PosthogIdentify } from "./components/PosthogIdentify";
|
||||
|
||||
const EnvLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
@@ -24,27 +14,16 @@ const EnvLayout = async (props: {
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session?.user) {
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return redirect(`/auth/login`);
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError(t("common.not_authorized"));
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(params.environmentId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
@@ -57,23 +36,16 @@ const EnvLayout = async (props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponseFilterProvider>
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
environmentId={params.environmentId}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
/>
|
||||
<FormbricksClient userId={user.id} email={user.email} />
|
||||
<ToasterClient />
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
</ResponseFilterProvider>
|
||||
</EnvironmentIdBaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -71,7 +71,11 @@ const getQuestionColumnsData = (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
||||
<span className="truncate">{getLocalizedValue(matrixRow, "default")}</span>
|
||||
<span className="truncate">
|
||||
{getLocalizedValue(question.headline, "default") +
|
||||
" - " +
|
||||
getLocalizedValue(matrixRow, "default")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,16 @@ export const DateQuestionSummary = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
@@ -70,8 +80,8 @@ export const DateQuestionSummary = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{formatDateWithOrdinal(new Date(response.value as string))}
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
|
||||
@@ -36,6 +36,9 @@ vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
POSTHOG_API_HOST: "test-posthog-api-host",
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
|
||||
|
||||
@@ -7,7 +7,14 @@ import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-cl
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Suspense } from "react";
|
||||
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants";
|
||||
import {
|
||||
FORMBRICKS_API_HOST,
|
||||
FORMBRICKS_ENVIRONMENT_ID,
|
||||
IS_FORMBRICKS_ENABLED,
|
||||
IS_POSTHOG_CONFIGURED,
|
||||
POSTHOG_API_HOST,
|
||||
POSTHOG_API_KEY,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
@@ -31,7 +38,15 @@ const AppLayout = async ({ children }) => {
|
||||
</Suspense>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<>
|
||||
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
|
||||
{user ? (
|
||||
<FormbricksClient
|
||||
userId={user.id}
|
||||
email={user.email}
|
||||
formbricksApiHost={FORMBRICKS_API_HOST}
|
||||
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
|
||||
formbricksEnabled={IS_FORMBRICKS_ENABLED}
|
||||
/>
|
||||
) : null}
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
|
||||
|
||||
export { GET };
|
||||
@@ -1,10 +1,6 @@
|
||||
import formbricks from "@formbricks/js";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
export const formbricksEnabled =
|
||||
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
|
||||
|
||||
export const formbricksLogout = async () => {
|
||||
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
|
||||
localStorage.clear();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -47,6 +47,8 @@ export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) =>
|
||||
},
|
||||
});
|
||||
}
|
||||
// We only want to run this once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -67,10 +67,11 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Date:
|
||||
if (typeof responseData === "string") {
|
||||
const formattedDateString = formatDateWithOrdinal(new Date(responseData));
|
||||
return (
|
||||
<p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDateString}</p>
|
||||
);
|
||||
const parsedDate = new Date(responseData);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
@@ -100,7 +101,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
return (
|
||||
<p
|
||||
key={rowValueInSelectedLanguage}
|
||||
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
|
||||
className="ph-no-capture my-1 font-normal text-slate-700 capitalize">
|
||||
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ZContactAttributeKeyInput,
|
||||
ZGetContactAttributeKeysFilter,
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
@@ -54,10 +55,12 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
|
||||
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
|
||||
"/contact-attribute-keys": {
|
||||
servers: managementServer,
|
||||
get: getContactAttributeKeysEndpoint,
|
||||
post: createContactAttributeKeyEndpoint,
|
||||
},
|
||||
"/contact-attribute-keys/{id}": {
|
||||
servers: managementServer,
|
||||
get: getContactAttributeKeyEndpoint,
|
||||
put: updateContactAttributeKeyEndpoint,
|
||||
delete: deleteContactAttributeKeyEndpoint,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZGetContactAttributeKeysFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ZContactAttributeInput,
|
||||
ZGetContactAttributesFilter,
|
||||
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
|
||||
@@ -54,10 +55,12 @@ export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
|
||||
export const contactAttributePaths: ZodOpenApiPathsObject = {
|
||||
"/contact-attributes": {
|
||||
servers: managementServer,
|
||||
get: getContactAttributesEndpoint,
|
||||
post: createContactAttributeEndpoint,
|
||||
},
|
||||
"/contact-attributes/{id}": {
|
||||
servers: managementServer,
|
||||
get: getContactAttributeEndpoint,
|
||||
put: updateContactAttributeEndpoint,
|
||||
delete: deleteContactAttributeEndpoint,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
updateContactEndpoint,
|
||||
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
|
||||
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZContact } from "@formbricks/database/zod/contact";
|
||||
@@ -56,10 +57,12 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
|
||||
|
||||
export const contactPaths: ZodOpenApiPathsObject = {
|
||||
"/contacts": {
|
||||
servers: managementServer,
|
||||
get: getContactsEndpoint,
|
||||
post: createContactEndpoint,
|
||||
},
|
||||
"/contacts/{id}": {
|
||||
servers: managementServer,
|
||||
get: getContactEndpoint,
|
||||
put: updateContactEndpoint,
|
||||
delete: deleteContactEndpoint,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZContact } from "@formbricks/database/zod/contact";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZGetContactsFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
|
||||
6
apps/web/modules/api/v2/management/lib/openapi.ts
Normal file
6
apps/web/modules/api/v2/management/lib/openapi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const managementServer = [
|
||||
{
|
||||
url: `https://app.formbricks.com/api/v2/management`,
|
||||
description: "Formbricks Management API",
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,4 @@
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import {
|
||||
deleteResponseEndpoint,
|
||||
getResponseEndpoint,
|
||||
@@ -56,10 +57,12 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
|
||||
export const responsePaths: ZodOpenApiPathsObject = {
|
||||
"/responses": {
|
||||
servers: managementServer,
|
||||
get: getResponsesEndpoint,
|
||||
post: createResponseEndpoint,
|
||||
},
|
||||
"/responses/{id}": {
|
||||
servers: managementServer,
|
||||
get: getResponseEndpoint,
|
||||
put: updateResponseEndpoint,
|
||||
delete: deleteResponseEndpoint,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ZContactLinkParams } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
|
||||
export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
|
||||
operationId: "getPersonalizedSurveyLink",
|
||||
summary: "Get personalized survey link for a contact",
|
||||
description: "Retrieves a personalized link for a specific survey.",
|
||||
requestParams: {
|
||||
path: ZContactLinkParams,
|
||||
},
|
||||
tags: ["Management API > Surveys > Contact Links"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Personalized survey link retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(
|
||||
z.object({
|
||||
data: z.object({
|
||||
surveyUrl: z.string().url(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -5,20 +5,14 @@ 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 {
|
||||
TContactLinkParams,
|
||||
ZContactLinkParams,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
|
||||
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";
|
||||
|
||||
const ZContactLinkParams = z.object({
|
||||
surveyId: ZId,
|
||||
contactId: ZId,
|
||||
});
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ surveyId: string; contactId: string }> }
|
||||
) =>
|
||||
export const GET = async (request: Request, props: { params: Promise<TContactLinkParams> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
externalParams: props.params,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZContactLinkParams = z.object({
|
||||
surveyId: z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
description: "The ID of the survey",
|
||||
param: { name: "surveyId", in: "path" },
|
||||
}),
|
||||
contactId: z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
description: "The ID of the contact",
|
||||
param: { name: "contactId", in: "path" },
|
||||
}),
|
||||
});
|
||||
|
||||
export type TContactLinkParams = z.infer<typeof ZContactLinkParams>;
|
||||
@@ -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,28 @@
|
||||
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 { 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: 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,58 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZContactLinksBySegmentParams = z.object({
|
||||
surveyId: z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
description: "The ID of the survey",
|
||||
param: { name: "surveyId", in: "path" },
|
||||
}),
|
||||
segmentId: z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
description: "The ID of the segment",
|
||||
param: { name: "segmentId", in: "path" },
|
||||
}),
|
||||
});
|
||||
|
||||
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,10 @@
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
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}": {
|
||||
servers: managementServer,
|
||||
get: getContactLinksBySegmentEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
deleteSurveyEndpoint,
|
||||
getSurveyEndpoint,
|
||||
updateSurveyEndpoint,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
|
||||
// import {
|
||||
// deleteSurveyEndpoint,
|
||||
// getSurveyEndpoint,
|
||||
// updateSurveyEndpoint,
|
||||
// } from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
|
||||
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
@@ -55,13 +57,19 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
|
||||
};
|
||||
|
||||
export const surveyPaths: ZodOpenApiPathsObject = {
|
||||
"/surveys": {
|
||||
get: getSurveysEndpoint,
|
||||
post: createSurveyEndpoint,
|
||||
},
|
||||
"/surveys/{id}": {
|
||||
get: getSurveyEndpoint,
|
||||
put: updateSurveyEndpoint,
|
||||
delete: deleteSurveyEndpoint,
|
||||
// "/surveys": {
|
||||
// servers: managementServer,
|
||||
// get: getSurveysEndpoint,
|
||||
// post: createSurveyEndpoint,
|
||||
// },
|
||||
// "/surveys/{id}": {
|
||||
// servers: managementServer,
|
||||
// get: getSurveyEndpoint,
|
||||
// put: updateSurveyEndpoint,
|
||||
// delete: deleteSurveyEndpoint,
|
||||
// },
|
||||
"/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
|
||||
servers: managementServer,
|
||||
get: getPersonalizedSurveyLink,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZGetSurveysFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import {
|
||||
deleteWebhookEndpoint,
|
||||
getWebhookEndpoint,
|
||||
@@ -56,10 +57,12 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
|
||||
export const webhookPaths: ZodOpenApiPathsObject = {
|
||||
"/webhooks": {
|
||||
servers: managementServer,
|
||||
get: getWebhooksEndpoint,
|
||||
post: createWebhookEndpoint,
|
||||
},
|
||||
"/webhooks/{id}": {
|
||||
servers: managementServer,
|
||||
get: getWebhookEndpoint,
|
||||
put: updateWebhookEndpoint,
|
||||
delete: deleteWebhookEndpoint,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
|
||||
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
|
||||
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
|
||||
// import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
|
||||
// import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
|
||||
// import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
|
||||
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
|
||||
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
|
||||
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
|
||||
import { mePaths } from "@/modules/api/v2/me/lib/openapi";
|
||||
@@ -39,10 +40,11 @@ const document = createDocument({
|
||||
...mePaths,
|
||||
...responsePaths,
|
||||
...bulkContactPaths,
|
||||
...contactPaths,
|
||||
...contactAttributePaths,
|
||||
...contactAttributeKeyPaths,
|
||||
// ...contactPaths,
|
||||
// ...contactAttributePaths,
|
||||
// ...contactAttributeKeyPaths,
|
||||
...surveyPaths,
|
||||
...surveyContactLinksBySegmentPaths,
|
||||
...webhookPaths,
|
||||
...teamPaths,
|
||||
...projectTeamPaths,
|
||||
@@ -50,7 +52,7 @@ const document = createDocument({
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2/management",
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
description: "Formbricks Cloud",
|
||||
},
|
||||
],
|
||||
@@ -83,6 +85,10 @@ const document = createDocument({
|
||||
name: "Management API > Surveys",
|
||||
description: "Operations for managing surveys.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Surveys > Contact Links",
|
||||
description: "Operations for generating personalized survey links for contacts.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Webhooks",
|
||||
description: "Operations for managing webhooks.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const organizationServer = [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2/organizations",
|
||||
description: "Formbricks Cloud",
|
||||
url: `https://app.formbricks.com/api/v2/organizations`,
|
||||
description: "Formbricks Organizations API",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetFilter = z.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
limit: z.coerce.number().min(1).max(250).optional().default(50).describe("Number of items to return"),
|
||||
skip: z.coerce.number().min(0).optional().default(0).describe("Number of items to skip"),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt").describe("Sort by field"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc").describe("Sort order"),
|
||||
startDate: z.coerce.date().optional().describe("Start date"),
|
||||
endDate: z.coerce.date().optional().describe("End date"),
|
||||
});
|
||||
|
||||
export type TGetFilter = z.infer<typeof ZGetFilter>;
|
||||
|
||||
@@ -63,12 +63,12 @@ export const LoginForm = ({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const callbackUrl = searchParams?.get("callbackUrl") || "";
|
||||
const callbackUrl = searchParams?.get("callbackUrl") ?? "";
|
||||
const { t } = useTranslate();
|
||||
|
||||
const form = useForm<TLoginForm>({
|
||||
defaultValues: {
|
||||
email: searchParams?.get("email") || "",
|
||||
email: searchParams?.get("email") ?? "",
|
||||
password: "",
|
||||
totpCode: "",
|
||||
backupCode: "",
|
||||
@@ -112,7 +112,7 @@ export const LoginForm = ({
|
||||
}
|
||||
|
||||
if (!signInResponse?.error) {
|
||||
router.push(searchParams?.get("callbackUrl") || "/");
|
||||
router.push(searchParams?.get("callbackUrl") ?? "/");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.toString());
|
||||
@@ -142,7 +142,7 @@ export const LoginForm = ({
|
||||
}
|
||||
|
||||
return t("auth.login.login_to_your_account");
|
||||
}, [totpBackup, totpLogin]);
|
||||
}, [t, totpBackup, totpLogin]);
|
||||
|
||||
const TwoFactorComponent = useMemo(() => {
|
||||
if (totpBackup) {
|
||||
@@ -154,7 +154,7 @@ export const LoginForm = ({
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [totpBackup, totpLogin]);
|
||||
}, [form, totpBackup, totpLogin]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
|
||||
377
apps/web/modules/auth/signup/components/signup-form.test.tsx
Normal file
377
apps/web/modules/auth/signup/components/signup-form.test.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmailTokenAction } from "../../../auth/actions";
|
||||
import { SignupForm } from "./signup-form";
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
}));
|
||||
|
||||
// Set up a push mock for useRouter
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
useSearchParams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-turnstile", () => ({
|
||||
useTurnstile: () => ({
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
default: (props: any) => (
|
||||
<div
|
||||
data-testid="turnstile"
|
||||
onClick={() => {
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess("test-turnstile-token");
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/signup/actions", () => ({
|
||||
createUserAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../auth/actions", () => ({
|
||||
createEmailTokenAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
|
||||
vi.mock("@/modules/ee/sso/components/sso-options", () => ({
|
||||
SSOOptions: () => <div data-testid="sso-options">SSOOptions</div>,
|
||||
}));
|
||||
vi.mock("@/modules/auth/signup/components/terms-privacy-links", () => ({
|
||||
TermsPrivacyLinks: () => <div data-testid="terms-privacy-links">TermsPrivacyLinks</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: (props: any) => <button {...props}>{props.children}</button>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: (props: any) => <input {...props} />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/password-input", () => ({
|
||||
PasswordInput: (props: any) => <input type="password" {...props} />,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
webAppUrl: "http://localhost",
|
||||
privacyUrl: "http://localhost/privacy",
|
||||
termsUrl: "http://localhost/terms",
|
||||
emailAuthEnabled: true,
|
||||
googleOAuthEnabled: false,
|
||||
githubOAuthEnabled: false,
|
||||
azureOAuthEnabled: false,
|
||||
oidcOAuthEnabled: false,
|
||||
userLocale: "en-US",
|
||||
emailVerificationDisabled: false,
|
||||
isSsoEnabled: false,
|
||||
samlSsoEnabled: false,
|
||||
isTurnstileConfigured: false,
|
||||
samlTenant: "",
|
||||
samlProduct: "",
|
||||
defaultOrganizationId: "org1",
|
||||
defaultOrganizationRole: "member",
|
||||
turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false
|
||||
} as const;
|
||||
|
||||
describe("SignupForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("toggles the signup form on button click", () => {
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Initially, the signup form is hidden.
|
||||
try {
|
||||
screen.getByTestId("signup-name");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
// Click the button to reveal the signup form.
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Now the input fields should appear.
|
||||
expect(screen.getByTestId("signup-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-email")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-password")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the form successfully", async () => {
|
||||
// Set up mocks for the API actions.
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Click the button to reveal the signup form.
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
const nameInput = screen.getByTestId("signup-name");
|
||||
const emailInput = screen.getByTestId("signup-email");
|
||||
const passwordInput = screen.getByTestId("signup-password");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "Test User" } });
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "Password123" } });
|
||||
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
// Since email verification is enabled (emailVerificationDisabled is false),
|
||||
// router.push should be called with the verification URL.
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
|
||||
});
|
||||
|
||||
it("submits the form successfully when turnstile is configured", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Simulate receiving a turnstile token by clicking the Turnstile element.
|
||||
const turnstileElement = screen.getByTestId("turnstile");
|
||||
fireEvent.click(turnstileElement);
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success");
|
||||
});
|
||||
|
||||
it("submits the form successfully when turnstile is configured, but createEmailTokenAction don't return data", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue(undefined);
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValue("error");
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Simulate receiving a turnstile token by clicking the Turnstile element.
|
||||
const turnstileElement = screen.getByTestId("turnstile");
|
||||
fireEvent.click(turnstileElement);
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
|
||||
// Since Turnstile is configured, but no token is received, an error message should be shown.
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error message if turnstile is configured, but no token is received", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
// Since Turnstile is configured, but no token is received, an error message should be shown.
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("auth.signup.please_verify_captcha");
|
||||
});
|
||||
});
|
||||
|
||||
it("Invite token is in the search params", async () => {
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("inviteToken=token123") as any);
|
||||
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
// Check that the invite token is passed to the createUserAction
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "token123",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import { FormProvider, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import Turnstile, { useTurnstile } from "react-turnstile";
|
||||
import { z } from "zod";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
import { createEmailTokenAction } from "../../../auth/actions";
|
||||
@@ -31,8 +30,6 @@ const ZSignupInput = z.object({
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
const turnstileSiteKey = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||
|
||||
type TSignupInput = z.infer<typeof ZSignupInput>;
|
||||
|
||||
interface SignupFormProps {
|
||||
@@ -55,6 +52,7 @@ interface SignupFormProps {
|
||||
isTurnstileConfigured: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
turnstileSiteKey?: string;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -77,6 +75,7 @@ export const SignupForm = ({
|
||||
isTurnstileConfigured,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
turnstileSiteKey,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -171,10 +170,11 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
data-testid="signup-name"
|
||||
value={field.value}
|
||||
name="name"
|
||||
autoFocus
|
||||
onChange={(name) => field.onChange(name)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="bg-white"
|
||||
/>
|
||||
@@ -192,9 +192,10 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
data-testid="signup-email"
|
||||
value={field.value}
|
||||
name="email"
|
||||
onChange={(email) => field.onChange(email)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="work@email.com"
|
||||
className="bg-white"
|
||||
/>
|
||||
@@ -212,10 +213,11 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
data-testid="signup-password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
@@ -248,6 +250,7 @@ export const SignupForm = ({
|
||||
|
||||
{showLogin && (
|
||||
<Button
|
||||
data-testid="signup-submit"
|
||||
type="submit"
|
||||
className="h-10 w-full justify-center"
|
||||
loading={form.formState.isSubmitting}
|
||||
@@ -258,6 +261,7 @@ export const SignupForm = ({
|
||||
|
||||
{!showLogin && (
|
||||
<Button
|
||||
data-testid="signup-show-login"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -50,23 +51,50 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
// Mock environment variables and constants
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
SAML_AUDIENCE: "test-saml-audience",
|
||||
SAML_PATH: "test-saml-path",
|
||||
SAML_DATABASE_URL: "test-saml-database-url",
|
||||
TERMS_URL: "test-terms-url",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
PRIVACY_URL: "test-privacy-url",
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
GOOGLE_OAUTH_ENABLED: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
AZURE_OAUTH_ENABLED: true,
|
||||
OIDC_OAUTH_ENABLED: true,
|
||||
OIDC_DISPLAY_NAME: "OpenID",
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
SAML_TENANT: "test-tenant",
|
||||
SAML_PRODUCT: "test-product",
|
||||
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
|
||||
IS_TURNSTILE_CONFIGURED: true,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
DEFAULT_ORGANIZATION_ID: "test-org-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "admin",
|
||||
SAML_TENANT: "test-saml-tenant",
|
||||
SAML_PRODUCT: "test-saml-product",
|
||||
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
}));
|
||||
|
||||
describe("SignupPage", () => {
|
||||
@@ -88,8 +116,11 @@ describe("SignupPage", () => {
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
|
||||
|
||||
const result = await SignupPage({ searchParams: mockSearchParams });
|
||||
@@ -128,7 +159,10 @@ describe("SignupPage", () => {
|
||||
it("calls notFound when invite token is valid but invite is not found", async () => {
|
||||
// Mock the license check functions to return false
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(false);
|
||||
|
||||
await SignupPage({ searchParams: { inviteToken: "test-token" } });
|
||||
@@ -141,8 +175,11 @@ describe("SignupPage", () => {
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
|
||||
|
||||
const result = await SignupPage({ searchParams: { email: "test@example.com" } });
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SAML_TENANT,
|
||||
SIGNUP_ENABLED,
|
||||
TERMS_URL,
|
||||
TURNSTILE_SITE_KEY,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
@@ -83,6 +84,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,9 @@ export const createSubscription = async (
|
||||
payment_method_data: { allow_redisplay: "always" },
|
||||
...(!isNewOrganization && {
|
||||
customer: organization.billing.stripeCustomerId ?? undefined,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
@@ -54,6 +55,7 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
|
||||
|
||||
export const bulkContactPaths: ZodOpenApiPathsObject = {
|
||||
"/contacts/bulk": {
|
||||
servers: managementServer,
|
||||
put: bulkContactEndpoint,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getProjectIdFromSegmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper";
|
||||
import {
|
||||
cloneSegment,
|
||||
createSegment,
|
||||
@@ -120,6 +121,8 @@ export const updateSegmentAction = authenticatedActionClient
|
||||
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
await checkForRecursiveSegmentFilter(parsedFilters.data, parsedInput.segmentId);
|
||||
}
|
||||
|
||||
return await updateSegment(parsedInput.segmentId, parsedInput.data);
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import * as helper from "@/lib/utils/helper";
|
||||
import * as actions from "@/modules/ee/contacts/segments/actions";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SafeParseReturnType } from "zod";
|
||||
import { TBaseFilters, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { SegmentSettings } from "./segment-settings";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/actions", () => ({
|
||||
updateSegmentAction: vi.fn(),
|
||||
deleteSegmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ZSegmentFilters validation
|
||||
vi.mock("@formbricks/types/segment", () => ({
|
||||
ZSegmentFilters: {
|
||||
safeParse: vi.fn().mockReturnValue({ success: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock components used by SegmentSettings
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, loading, disabled }: any) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-testid={
|
||||
children === "common.save_changes"
|
||||
? "save-button"
|
||||
: children === "common.add_filter"
|
||||
? "add-filter-button"
|
||||
: undefined
|
||||
}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ value, onChange, disabled, placeholder }: any) => (
|
||||
<input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
data-testid="input"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/confirm-delete-segment-modal", () => ({
|
||||
ConfirmDeleteSegmentModal: ({ open, setOpen, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-modal">
|
||||
<button onClick={onDelete} data-testid="confirm-delete">
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("./segment-editor", () => ({
|
||||
SegmentEditor: ({ group }) => (
|
||||
<div data-testid="segment-editor">
|
||||
Segment Editor
|
||||
<div data-testid="filter-count">{group?.length || 0}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./add-filter-modal", () => ({
|
||||
AddFilterModal: ({ open, setOpen, onAddFilter }: any) =>
|
||||
open ? (
|
||||
<div data-testid="add-filter-modal">
|
||||
<button
|
||||
onClick={() => {
|
||||
onAddFilter({
|
||||
type: "attribute",
|
||||
attributeKey: "testKey",
|
||||
operator: "equals",
|
||||
value: "testValue",
|
||||
connector: "and",
|
||||
});
|
||||
setOpen(false); // Close the modal after adding filter
|
||||
}}
|
||||
data-testid="add-test-filter">
|
||||
Add Filter
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe("SegmentSettings", () => {
|
||||
const mockProps = {
|
||||
environmentId: "env-123",
|
||||
initialSegment: {
|
||||
id: "segment-123",
|
||||
title: "Test Segment",
|
||||
description: "Test Description",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env-123",
|
||||
surveys: [],
|
||||
},
|
||||
setOpen: vi.fn(),
|
||||
contactAttributeKeys: [],
|
||||
segments: [],
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(helper.getFormattedErrorMessage).mockReturnValue("");
|
||||
// Default to valid filters
|
||||
vi.mocked(ZSegmentFilters.safeParse).mockReturnValue({ success: true } as unknown as SafeParseReturnType<
|
||||
TBaseFilters,
|
||||
TBaseFilters
|
||||
>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should update the segment and display a success message when valid data is provided", async () => {
|
||||
// Mock successful update
|
||||
vi.mocked(actions.updateSegmentAction).mockResolvedValue({
|
||||
data: {
|
||||
title: "Updated Segment",
|
||||
description: "Updated Description",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
createdAt: new Date(),
|
||||
environmentId: "env-123",
|
||||
id: "segment-123",
|
||||
surveys: [],
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Render component
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Find and click the save button using data-testid
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Verify updateSegmentAction was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(actions.updateSegmentAction).toHaveBeenCalledWith({
|
||||
environmentId: mockProps.environmentId,
|
||||
segmentId: mockProps.initialSegment.id,
|
||||
data: {
|
||||
title: mockProps.initialSegment.title,
|
||||
description: mockProps.initialSegment.description,
|
||||
isPrivate: mockProps.initialSegment.isPrivate,
|
||||
filters: mockProps.initialSegment.filters,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Verify success toast was displayed
|
||||
expect(toast.success).toHaveBeenCalledWith("Segment updated successfully!");
|
||||
|
||||
// Verify state was reset and router was refreshed
|
||||
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should update segment title when input changes", () => {
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Find title input and change its value
|
||||
const titleInput = screen.getAllByTestId("input")[0];
|
||||
fireEvent.change(titleInput, { target: { value: "Updated Title" } });
|
||||
|
||||
// Find and click the save button using data-testid
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Verify updateSegmentAction was called with updated title
|
||||
expect(actions.updateSegmentAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
title: "Updated Title",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should reset state after successfully updating a segment", async () => {
|
||||
// Mock successful update
|
||||
vi.mocked(actions.updateSegmentAction).mockResolvedValue({
|
||||
data: {
|
||||
title: "Updated Segment",
|
||||
description: "Updated Description",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
createdAt: new Date(),
|
||||
environmentId: "env-123",
|
||||
id: "segment-123",
|
||||
surveys: [],
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Render component
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Modify the segment state by changing the title
|
||||
const titleInput = screen.getAllByTestId("input")[0];
|
||||
fireEvent.change(titleInput, { target: { value: "Modified Title" } });
|
||||
|
||||
// Find and click the save button
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for the update to complete
|
||||
await waitFor(() => {
|
||||
// Verify updateSegmentAction was called
|
||||
expect(actions.updateSegmentAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify success toast was displayed
|
||||
expect(toast.success).toHaveBeenCalledWith("Segment updated successfully!");
|
||||
|
||||
// Verify state was reset by checking that setOpen was called with false
|
||||
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
|
||||
|
||||
// Re-render the component to verify it would use the initialSegment
|
||||
cleanup();
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Check that the title is back to the initial value
|
||||
const titleInputAfterReset = screen.getAllByTestId("input")[0];
|
||||
expect(titleInputAfterReset).toHaveValue("Test Segment");
|
||||
});
|
||||
|
||||
test("should not reset state if update returns an error message", async () => {
|
||||
// Mock update with error
|
||||
vi.mocked(actions.updateSegmentAction).mockResolvedValue({});
|
||||
vi.mocked(helper.getFormattedErrorMessage).mockReturnValue("Recursive segment filter detected");
|
||||
|
||||
// Render component
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Modify the segment state
|
||||
const titleInput = screen.getAllByTestId("input")[0];
|
||||
fireEvent.change(titleInput, { target: { value: "Modified Title" } });
|
||||
|
||||
// Find and click the save button
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for the update to complete
|
||||
await waitFor(() => {
|
||||
expect(actions.updateSegmentAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify error toast was displayed
|
||||
expect(toast.error).toHaveBeenCalledWith("Recursive segment filter detected");
|
||||
|
||||
// Verify state was NOT reset (setOpen should not be called)
|
||||
expect(mockProps.setOpen).not.toHaveBeenCalled();
|
||||
|
||||
// Verify isUpdatingSegment was set back to false
|
||||
expect(saveButton).not.toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
test("should delete the segment and display a success message when delete operation is successful", async () => {
|
||||
// Mock successful delete
|
||||
vi.mocked(actions.deleteSegmentAction).mockResolvedValue({});
|
||||
|
||||
// Render component
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Find and click the delete button to open the confirmation modal
|
||||
const deleteButton = screen.getByText("common.delete");
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// Verify the delete confirmation modal is displayed
|
||||
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
|
||||
|
||||
// Click the confirm delete button in the modal
|
||||
const confirmDeleteButton = screen.getByTestId("confirm-delete");
|
||||
fireEvent.click(confirmDeleteButton);
|
||||
|
||||
// Verify deleteSegmentAction was called with correct segment ID
|
||||
await waitFor(() => {
|
||||
expect(actions.deleteSegmentAction).toHaveBeenCalledWith({
|
||||
segmentId: mockProps.initialSegment.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify success toast was displayed with the correct message
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.segments.segment_deleted_successfully");
|
||||
|
||||
// Verify state was reset and router was refreshed
|
||||
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should disable the save button if the segment title is empty or filters are invalid", async () => {
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Initially the save button should be enabled because we have a valid title and filters
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
|
||||
// Change the title to empty string
|
||||
const titleInput = screen.getAllByTestId("input")[0];
|
||||
fireEvent.change(titleInput, { target: { value: "" } });
|
||||
|
||||
// Save button should now be disabled due to empty title
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
// Reset title to valid value
|
||||
fireEvent.change(titleInput, { target: { value: "Valid Title" } });
|
||||
|
||||
// Save button should be enabled again
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Now simulate invalid filters
|
||||
vi.mocked(ZSegmentFilters.safeParse).mockReturnValue({ success: false } as unknown as SafeParseReturnType<
|
||||
TBaseFilters,
|
||||
TBaseFilters
|
||||
>);
|
||||
|
||||
// We need to trigger a re-render to see the effect of the mocked validation
|
||||
// Adding a filter would normally trigger this, but we can simulate by changing any state
|
||||
const descriptionInput = screen.getAllByTestId("input")[1];
|
||||
fireEvent.change(descriptionInput, { target: { value: "Updated description" } });
|
||||
|
||||
// Save button should be disabled due to invalid filters
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
// Reset filters to valid
|
||||
vi.mocked(ZSegmentFilters.safeParse).mockReturnValue({ success: true } as unknown as SafeParseReturnType<
|
||||
TBaseFilters,
|
||||
TBaseFilters
|
||||
>);
|
||||
|
||||
// Change description again to trigger re-render
|
||||
fireEvent.change(descriptionInput, { target: { value: "Another description update" } });
|
||||
|
||||
// Save button should be enabled again
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error message and not proceed with update when recursive segment filter is detected", async () => {
|
||||
// Mock updateSegmentAction to return data that would contain an error
|
||||
const mockData = { someData: "value" };
|
||||
vi.mocked(actions.updateSegmentAction).mockResolvedValue(mockData as unknown as any);
|
||||
|
||||
// Mock getFormattedErrorMessage to return a recursive filter error message
|
||||
const recursiveErrorMessage = "Segment cannot reference itself in filters";
|
||||
vi.mocked(helper.getFormattedErrorMessage).mockReturnValue(recursiveErrorMessage);
|
||||
|
||||
// Render component
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Find and click the save button
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Verify updateSegmentAction was called
|
||||
await waitFor(() => {
|
||||
expect(actions.updateSegmentAction).toHaveBeenCalledWith({
|
||||
environmentId: mockProps.environmentId,
|
||||
segmentId: mockProps.initialSegment.id,
|
||||
data: {
|
||||
title: mockProps.initialSegment.title,
|
||||
description: mockProps.initialSegment.description,
|
||||
isPrivate: mockProps.initialSegment.isPrivate,
|
||||
filters: mockProps.initialSegment.filters,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Verify getFormattedErrorMessage was called with the data returned from updateSegmentAction
|
||||
expect(helper.getFormattedErrorMessage).toHaveBeenCalledWith(mockData);
|
||||
|
||||
// Verify error toast was displayed with the recursive filter error message
|
||||
expect(toast.error).toHaveBeenCalledWith(recursiveErrorMessage);
|
||||
|
||||
// Verify that the update operation was halted (router.refresh and setOpen should not be called)
|
||||
expect(mockProps.setOpen).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that success toast was not displayed
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the button is no longer in loading state
|
||||
// This is checking that setIsUpdatingSegment(false) was called
|
||||
const updatedSaveButton = screen.getByTestId("save-button");
|
||||
expect(updatedSaveButton.getAttribute("data-loading")).not.toBe("true");
|
||||
});
|
||||
|
||||
test("should display server error message when updateSegmentAction returns a non-recursive filter error", async () => {
|
||||
// Mock server error response
|
||||
const serverErrorMessage = "Database connection error";
|
||||
vi.mocked(actions.updateSegmentAction).mockResolvedValue({ serverError: "Database connection error" });
|
||||
vi.mocked(helper.getFormattedErrorMessage).mockReturnValue(serverErrorMessage);
|
||||
|
||||
// Render component
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Find and click the save button
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Verify updateSegmentAction was called
|
||||
await waitFor(() => {
|
||||
expect(actions.updateSegmentAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify getFormattedErrorMessage was called with the response from updateSegmentAction
|
||||
expect(helper.getFormattedErrorMessage).toHaveBeenCalledWith({
|
||||
serverError: "Database connection error",
|
||||
});
|
||||
|
||||
// Verify error toast was displayed with the server error message
|
||||
expect(toast.error).toHaveBeenCalledWith(serverErrorMessage);
|
||||
|
||||
// Verify that setOpen was not called (update process should stop)
|
||||
expect(mockProps.setOpen).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the loading state was reset
|
||||
const updatedSaveButton = screen.getByTestId("save-button");
|
||||
expect(updatedSaveButton.getAttribute("data-loading")).not.toBe("true");
|
||||
});
|
||||
|
||||
// [Tusk] FAILING TEST
|
||||
test("should add a filter to the segment when a valid filter is selected in the filter modal", async () => {
|
||||
// Render component
|
||||
render(<SegmentSettings {...mockProps} />);
|
||||
|
||||
// Verify initial filter count is 0
|
||||
expect(screen.getByTestId("filter-count").textContent).toBe("0");
|
||||
|
||||
// Find and click the add filter button
|
||||
const addFilterButton = screen.getByTestId("add-filter-button");
|
||||
fireEvent.click(addFilterButton);
|
||||
|
||||
// Verify filter modal is open
|
||||
expect(screen.getByTestId("add-filter-modal")).toBeInTheDocument();
|
||||
|
||||
// Select a filter from the modal
|
||||
const addTestFilterButton = screen.getByTestId("add-test-filter");
|
||||
fireEvent.click(addTestFilterButton);
|
||||
|
||||
// Verify filter modal is closed and filter is added
|
||||
expect(screen.queryByTestId("add-filter-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Verify filter count is now 1
|
||||
expect(screen.getByTestId("filter-count").textContent).toBe("1");
|
||||
|
||||
// Verify the save button is enabled
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
|
||||
// Click save and verify the segment with the new filter is saved
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(actions.updateSegmentAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
filters: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "attribute",
|
||||
attributeKey: "testKey",
|
||||
connector: null,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteSegmentAction, updateSegmentAction } from "@/modules/ee/contacts/segments/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmDeleteSegmentModal } from "@/modules/ui/components/confirm-delete-segment-modal";
|
||||
@@ -73,7 +74,7 @@ export function SegmentSettings({
|
||||
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
await updateSegmentAction({
|
||||
const data = await updateSegmentAction({
|
||||
environmentId,
|
||||
segmentId: segment.id,
|
||||
data: {
|
||||
@@ -84,15 +85,18 @@ export function SegmentSettings({
|
||||
},
|
||||
});
|
||||
|
||||
if (!data?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(data);
|
||||
|
||||
toast.error(errorMessage);
|
||||
setIsUpdatingSegment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingSegment(false);
|
||||
toast.success("Segment updated successfully!");
|
||||
} catch (err: any) {
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
toast.error(t("environments.segments.invalid_segment_filters"));
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
setIsUpdatingSegment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export const SegmentTableDataRowContainer = async ({
|
||||
? surveys.filter((survey) => ["draft", "paused"].includes(survey.status)).map((survey) => survey.name)
|
||||
: [];
|
||||
|
||||
const filteredSegments = segments.filter((segment) => segment.id !== currentSegment.id);
|
||||
|
||||
return (
|
||||
<SegmentTableDataRow
|
||||
currentSegment={{
|
||||
@@ -35,7 +37,7 @@ export const SegmentTableDataRowContainer = async ({
|
||||
activeSurveys,
|
||||
inactiveSurveys,
|
||||
}}
|
||||
segments={segments}
|
||||
segments={filteredSegments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal file
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import {
|
||||
TBaseFilters,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import { getSegment } from "../segments";
|
||||
|
||||
// Type for the result of the segment filter to prisma query generation
|
||||
export type SegmentFilterQueryResult = {
|
||||
whereClause: Prisma.ContactWhereInput;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment attribute filter
|
||||
*/
|
||||
const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
// This base query checks if the contact has an attribute with the specified key
|
||||
const baseQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: contactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Handle special operators that don't require a value
|
||||
if (operator === "isSet") {
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (operator === "isNotSet") {
|
||||
return {
|
||||
NOT: baseQuery,
|
||||
};
|
||||
}
|
||||
|
||||
// For all other operators, we need to check the attribute value
|
||||
const valueQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: contactAttributeKey,
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
// Apply the appropriate operator to the attribute value
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "notEquals":
|
||||
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "contains":
|
||||
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "doesNotContain":
|
||||
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
|
||||
break;
|
||||
case "startsWith":
|
||||
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "endsWith":
|
||||
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "greaterThan":
|
||||
valueQuery.attributes.some.value = { gt: String(value) };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
valueQuery.attributes.some.value = { gte: String(value) };
|
||||
break;
|
||||
case "lessThan":
|
||||
valueQuery.attributes.some.value = { lt: String(value) };
|
||||
break;
|
||||
case "lessEqual":
|
||||
valueQuery.attributes.some.value = { lte: String(value) };
|
||||
break;
|
||||
default:
|
||||
valueQuery.attributes.some.value = String(value);
|
||||
}
|
||||
|
||||
return valueQuery;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a person filter
|
||||
*/
|
||||
const buildPersonFilterWhereClause = (filter: TSegmentPersonFilter): Prisma.ContactWhereInput => {
|
||||
const { personIdentifier } = filter.root;
|
||||
|
||||
if (personIdentifier === "userId") {
|
||||
const personFilter: TSegmentAttributeFilter = {
|
||||
...filter,
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: personIdentifier,
|
||||
},
|
||||
};
|
||||
return buildAttributeFilterWhereClause(personFilter);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a device filter
|
||||
*/
|
||||
const buildDeviceFilterWhereClause = (filter: TSegmentDeviceFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { type } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
const baseQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: type,
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
if (operator === "equals") {
|
||||
baseQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
|
||||
} else if (operator === "notEquals") {
|
||||
baseQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment filter
|
||||
*/
|
||||
const buildSegmentFilterWhereClause = async (
|
||||
filter: TSegmentSegmentFilter,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root } = filter;
|
||||
const { segmentId } = root;
|
||||
|
||||
if (segmentPath.has(segmentId)) {
|
||||
logger.error(
|
||||
{ segmentId, path: Array.from(segmentPath) },
|
||||
"Circular reference detected in segment filter"
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const segment = await getSegment(segmentId);
|
||||
|
||||
if (!segment) {
|
||||
logger.error({ segmentId }, "Segment not found");
|
||||
return {};
|
||||
}
|
||||
|
||||
const newPath = new Set(segmentPath);
|
||||
newPath.add(segmentId);
|
||||
|
||||
return processFilters(segment.filters, newPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes a segment filter or group and returns a Prisma where clause
|
||||
*/
|
||||
const processSingleFilter = async (
|
||||
filter: TSegmentFilter,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root } = filter;
|
||||
|
||||
switch (root.type) {
|
||||
case "attribute":
|
||||
return buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
|
||||
case "person":
|
||||
return buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
|
||||
case "device":
|
||||
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter);
|
||||
case "segment":
|
||||
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes filters and returns a combined Prisma where clause
|
||||
*/
|
||||
const processFilters = async (
|
||||
filters: TBaseFilters,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
if (filters.length === 0) return {};
|
||||
|
||||
const query: { AND: Prisma.ContactWhereInput[]; OR: Prisma.ContactWhereInput[] } = {
|
||||
AND: [],
|
||||
OR: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
const { resource, connector } = filters[i];
|
||||
let whereClause: Prisma.ContactWhereInput;
|
||||
|
||||
// Process the resource based on its type
|
||||
if (isResourceFilter(resource)) {
|
||||
// If it's a single filter, process it directly
|
||||
whereClause = await processSingleFilter(resource, segmentPath);
|
||||
} else {
|
||||
// If it's a group of filters, process it recursively
|
||||
whereClause = await processFilters(resource, segmentPath);
|
||||
}
|
||||
|
||||
if (Object.keys(whereClause).length === 0) continue;
|
||||
if (filters.length === 1) query.AND = [whereClause];
|
||||
else {
|
||||
if (i === 0) {
|
||||
if (filters[1].connector === "and") query.AND.push(whereClause);
|
||||
else query.OR.push(whereClause);
|
||||
} else {
|
||||
if (connector === "and") query.AND.push(whereClause);
|
||||
else query.OR.push(whereClause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(query.AND.length > 0 ? { AND: query.AND } : {}),
|
||||
...(query.OR.length > 0 ? { OR: query.OR } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms a segment filter into a Prisma query for contacts
|
||||
*/
|
||||
export const segmentFilterToPrismaQuery = reactCache(
|
||||
async (segmentId: string, filters: TBaseFilters, environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<SegmentFilterQueryResult, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const baseWhereClause = {
|
||||
environmentId,
|
||||
};
|
||||
|
||||
// Initialize an empty stack for tracking the current evaluation path
|
||||
const segmentPath = new Set<string>([segmentId]);
|
||||
const filtersWhereClause = await processFilters(filters, segmentPath);
|
||||
|
||||
const whereClause = {
|
||||
AND: [baseWhereClause, filtersWhereClause],
|
||||
};
|
||||
|
||||
return ok({ whereClause });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, segmentId, environmentId },
|
||||
"Error transforming segment filter to Prisma query"
|
||||
);
|
||||
return err({
|
||||
type: "bad_request",
|
||||
message: "Failed to convert segment filters to Prisma query",
|
||||
details: [{ field: "segment", issue: "Invalid segment filters" }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`segmentFilterToPrismaQuery-${segmentId}-${environmentId}-${JSON.stringify(filters)}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byEnvironmentId(environmentId), segmentCache.tag.byId(segmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
38
apps/web/modules/ee/contacts/segments/lib/helper.ts
Normal file
38
apps/web/modules/ee/contacts/segments/lib/helper.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TBaseFilters } from "@formbricks/types/segment";
|
||||
|
||||
/**
|
||||
* Checks if a segment filter contains a recursive reference to itself
|
||||
* @param filters - The filters to check for recursive references
|
||||
* @param segmentId - The ID of the segment being checked
|
||||
* @throws {InvalidInputError} When a recursive segment filter is detected
|
||||
*/
|
||||
export const checkForRecursiveSegmentFilter = async (filters: TBaseFilters, segmentId: string) => {
|
||||
for (const filter of filters) {
|
||||
const { resource } = filter;
|
||||
if (isResourceFilter(resource)) {
|
||||
if (resource.root.type === "segment") {
|
||||
const { segmentId: segmentIdFromRoot } = resource.root;
|
||||
|
||||
if (segmentIdFromRoot === segmentId) {
|
||||
throw new InvalidInputError("Recursive segment filter is not allowed");
|
||||
}
|
||||
|
||||
const segment = await getSegment(segmentIdFromRoot);
|
||||
|
||||
if (segment) {
|
||||
// recurse into this segment and check for recursive filters:
|
||||
const segmentFilters = segment.filters;
|
||||
|
||||
if (segmentFilters) {
|
||||
await checkForRecursiveSegmentFilter(segmentFilters, segmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await checkForRecursiveSegmentFilter(resource, segmentId);
|
||||
}
|
||||
}
|
||||
};
|
||||
213
apps/web/modules/ee/contacts/segments/lib/tests/helper.test.ts
Normal file
213
apps/web/modules/ee/contacts/segments/lib/tests/helper.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper";
|
||||
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TBaseFilters, TSegment } from "@formbricks/types/segment";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
getSegment: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("checkForRecursiveSegmentFilter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError when a filter references the same segment ID as the one being checked", async () => {
|
||||
// Arrange
|
||||
const segmentId = "segment-123";
|
||||
|
||||
// Create a filter that references the same segment ID
|
||||
const filters = [
|
||||
{
|
||||
operator: "and",
|
||||
resource: {
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId, // This creates the recursive reference
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
checkForRecursiveSegmentFilter(filters as unknown as TBaseFilters, segmentId)
|
||||
).rejects.toThrow(new InvalidInputError("Recursive segment filter is not allowed"));
|
||||
|
||||
// Verify that getSegment was not called since the function should throw before reaching that point
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should complete successfully when filters do not reference the same segment ID as the one being checked", async () => {
|
||||
// Arrange
|
||||
const segmentId = "segment-123";
|
||||
const differentSegmentId = "segment-456";
|
||||
|
||||
// Create a filter that references a different segment ID
|
||||
const filters = [
|
||||
{
|
||||
operator: "and",
|
||||
resource: {
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: differentSegmentId, // Different segment ID
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the referenced segment to have non-recursive filters
|
||||
const referencedSegment = {
|
||||
id: differentSegmentId,
|
||||
filters: [
|
||||
{
|
||||
operator: "and",
|
||||
resource: {
|
||||
root: {
|
||||
type: "attribute",
|
||||
attributeClassName: "user",
|
||||
attributeKey: "email",
|
||||
},
|
||||
operator: "equals",
|
||||
value: "test@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue(referencedSegment as unknown as TSegment);
|
||||
|
||||
// Act & Assert
|
||||
// The function should complete without throwing an error
|
||||
await expect(
|
||||
checkForRecursiveSegmentFilter(filters as unknown as TBaseFilters, segmentId)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// Verify that getSegment was called with the correct segment ID
|
||||
expect(getSegment).toHaveBeenCalledWith(differentSegmentId);
|
||||
expect(getSegment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should recursively check nested filters for recursive references and throw InvalidInputError", async () => {
|
||||
// Arrange
|
||||
const originalSegmentId = "segment-123";
|
||||
const nestedSegmentId = "segment-456";
|
||||
|
||||
// Create a filter that references another segment
|
||||
const filters = [
|
||||
{
|
||||
operator: "and",
|
||||
resource: {
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: nestedSegmentId, // This references another segment
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the nested segment to have a filter that references back to the original segment
|
||||
// This creates an indirect recursive reference
|
||||
vi.mocked(getSegment).mockResolvedValueOnce({
|
||||
id: nestedSegmentId,
|
||||
filters: [
|
||||
{
|
||||
operator: "and",
|
||||
resource: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: null,
|
||||
resource: {
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: originalSegmentId, // This creates the recursive reference back to the original segment
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
checkForRecursiveSegmentFilter(filters as unknown as TBaseFilters, originalSegmentId)
|
||||
).rejects.toThrow(new InvalidInputError("Recursive segment filter is not allowed"));
|
||||
|
||||
// Verify that getSegment was called with the nested segment ID
|
||||
expect(getSegment).toHaveBeenCalledWith(nestedSegmentId);
|
||||
|
||||
// Verify that getSegment was called exactly once
|
||||
expect(getSegment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should detect circular references between multiple segments", async () => {
|
||||
// Arrange
|
||||
const segmentIdA = "segment-A";
|
||||
const segmentIdB = "segment-B";
|
||||
const segmentIdC = "segment-C";
|
||||
|
||||
// Create filters for segment A that reference segment B
|
||||
const filtersA = [
|
||||
{
|
||||
operator: "and",
|
||||
resource: {
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: segmentIdB, // A references B
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Create filters for segment B that reference segment C
|
||||
const filtersB = [
|
||||
{
|
||||
operator: "and",
|
||||
resource: {
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: segmentIdC, // B references C
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Create filters for segment C that reference segment A (creating a circular reference)
|
||||
const filtersC = [
|
||||
{
|
||||
operator: "and",
|
||||
resource: {
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: segmentIdA, // C references back to A, creating a circular reference
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock getSegment to return appropriate segment data for each segment ID
|
||||
vi.mocked(getSegment).mockImplementation(async (id) => {
|
||||
if (id === segmentIdB) {
|
||||
return { id: segmentIdB, filters: filtersB } as any;
|
||||
} else if (id === segmentIdC) {
|
||||
return { id: segmentIdC, filters: filtersC } as any;
|
||||
}
|
||||
return { id, filters: [] } as any;
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
checkForRecursiveSegmentFilter(filtersA as unknown as TBaseFilters, segmentIdA)
|
||||
).rejects.toThrow(new InvalidInputError("Recursive segment filter is not allowed"));
|
||||
|
||||
// Verify that getSegment was called for segments B and C
|
||||
expect(getSegment).toHaveBeenCalledWith(segmentIdB);
|
||||
expect(getSegment).toHaveBeenCalledWith(segmentIdC);
|
||||
|
||||
// Verify the number of calls to getSegment (should be 2)
|
||||
expect(getSegment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -151,10 +151,10 @@ export const InsightSheet = ({
|
||||
<div className="flex flex-1 flex-col gap-y-2 overflow-auto">
|
||||
{deferredDocuments.map((document, index) => (
|
||||
<Card key={`${document.id}-${index}`} className="transition-opacity duration-200">
|
||||
<CardContent className="p-4 text-sm">
|
||||
<Markdown className="whitespace-pre-wrap">{document.text}</Markdown>
|
||||
<CardContent className="p-4 text-sm whitespace-pre-wrap">
|
||||
<Markdown>{document.text}</Markdown>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between rounded-bl-xl rounded-br-xl border-t border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
<CardFooter className="flex justify-between rounded-br-xl rounded-bl-xl border-t border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
<p>
|
||||
Sentiment: <SentimentSelect documentId={document.id} sentiment={document.sentiment} />
|
||||
</p>
|
||||
|
||||
@@ -14,8 +14,7 @@ interface TwoFactorBackupProps {
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
any
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ interface TwoFactorProps {
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
any
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
175
apps/web/modules/environments/lib/utils.test.ts
Normal file
175
apps/web/modules/environments/lib/utils.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// utils.test.ts
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
// Pull in the mocked implementations to configure them in tests
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { environmentIdLayoutChecks, getEnvironmentAuth } from "./utils";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
getProjectPermissionByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/utils/teams", () => ({
|
||||
getTeamPermissionFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/project/service", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/errors", () => ({
|
||||
AuthorizationError: class AuthorizationError extends Error {},
|
||||
}));
|
||||
|
||||
describe("utils.ts", () => {
|
||||
beforeEach(() => {
|
||||
// Provide default mocks for successful scenario
|
||||
vi.mocked(getTranslate).mockResolvedValue(((key: string) => key) as any); // Mock translation function
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } });
|
||||
vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ id: "proj123" } as TProject);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org123" } as TOrganization);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
role: "member",
|
||||
} as unknown as TMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isMember: true,
|
||||
isOwner: false,
|
||||
isManager: false,
|
||||
isBilling: false,
|
||||
});
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read");
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: true,
|
||||
hasReadWriteAccess: true,
|
||||
hasManageAccess: true,
|
||||
});
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user123" } as TUser);
|
||||
});
|
||||
|
||||
describe("getEnvironmentAuth", () => {
|
||||
test("returns environment data on success", async () => {
|
||||
const result = await getEnvironmentAuth("env123");
|
||||
expect(result.environment.id).toBe("env123");
|
||||
expect(result.project.id).toBe("proj123");
|
||||
expect(result.organization.id).toBe("org123");
|
||||
expect(result.session.user.id).toBe("user123");
|
||||
expect(result.isReadOnly).toBe(true); // from mocks (isMember = true & hasReadAccess = true)
|
||||
});
|
||||
|
||||
test("throws error if project not found", async () => {
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
||||
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.project_not_found");
|
||||
});
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.environment_not_found");
|
||||
});
|
||||
|
||||
test("throws error if session not found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.session_not_found");
|
||||
});
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
|
||||
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
test("throws error if membership not found", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
||||
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.membership_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environmentIdLayoutChecks", () => {
|
||||
test("returns t, session, user, and organization on success", async () => {
|
||||
const result = await environmentIdLayoutChecks("env123");
|
||||
expect(result.t).toBeInstanceOf(Function);
|
||||
expect(result.session?.user.id).toBe("user123");
|
||||
expect(result.user?.id).toBe("user123");
|
||||
expect(result.organization?.id).toBe("org123");
|
||||
});
|
||||
|
||||
test("returns session=null and user=null if session does not have user", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({});
|
||||
const result = await environmentIdLayoutChecks("env123");
|
||||
expect(result.session).toBe(null);
|
||||
expect(result.user).toBe(null);
|
||||
expect(result.organization).toBe(null);
|
||||
});
|
||||
|
||||
test("returns user=null if user is not found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user123" } });
|
||||
vi.mocked(getUser).mockResolvedValueOnce(null);
|
||||
const result = await environmentIdLayoutChecks("env123");
|
||||
expect(result.session?.user.id).toBe("user123");
|
||||
expect(result.user).toBe(null);
|
||||
expect(result.organization).toBe(null);
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user has no environment access", async () => {
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
|
||||
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,14 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { cache } from "react";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TEnvironmentAuth } from "../types/environment-auth";
|
||||
|
||||
/**
|
||||
@@ -74,3 +77,29 @@ export const getEnvironmentAuth = cache(async (environmentId: string): Promise<T
|
||||
isReadOnly,
|
||||
};
|
||||
});
|
||||
|
||||
export const environmentIdLayoutChecks = async (environmentId: string) => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return { t, session: null, user: null, organization: null };
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return { t, session, user: null, organization: null };
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError(t("common.not_authorized"));
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
return { t, session, user, organization };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -15,7 +16,7 @@ import { Modal } from "@/modules/ui/components/modal";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertTriangleIcon, ChevronDownIcon, Trash2Icon } from "lucide-react";
|
||||
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -389,11 +390,9 @@ export const AddApiKeyModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
|
||||
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
|
||||
<p>{t("environments.project.api_keys.api_key_security_warning")}</p>
|
||||
</div>
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import packageJson from "@/package.json";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getProjects } from "@formbricks/lib/project/service";
|
||||
import { DeleteProject } from "./components/delete-project";
|
||||
import { EditProjectNameForm } from "./components/edit-project-name-form";
|
||||
@@ -51,7 +51,7 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
|
||||
</SettingsCard>
|
||||
<div>
|
||||
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
|
||||
{!IS_FORMBRICKS_CLOUD && (
|
||||
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
|
||||
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
|
||||
)}
|
||||
</div>
|
||||
|
||||
97
apps/web/modules/setup/(fresh-instance)/signup/page.test.tsx
Normal file
97
apps/web/modules/setup/(fresh-instance)/signup/page.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { SignupPage } from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
SAML_AUDIENCE: "test-saml-audience",
|
||||
SAML_PATH: "test-saml-path",
|
||||
SAML_DATABASE_URL: "test-saml-database-url",
|
||||
TERMS_URL: "test-terms-url",
|
||||
SIGNUP_ENABLED: true,
|
||||
PRIVACY_URL: "test-privacy-url",
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
GOOGLE_OAUTH_ENABLED: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
AZURE_OAUTH_ENABLED: true,
|
||||
OIDC_OAUTH_ENABLED: true,
|
||||
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
|
||||
IS_TURNSTILE_CONFIGURED: true,
|
||||
SAML_TENANT: "test-saml-tenant",
|
||||
SAML_PRODUCT: "test-saml-product",
|
||||
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getisSsoEnabled: vi.fn(),
|
||||
getIsSamlSsoEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the SignupForm component to simplify our test assertions
|
||||
vi.mock("@/modules/auth/signup/components/signup-form", () => ({
|
||||
SignupForm: (props) => (
|
||||
<div data-testid="signup-form" data-turnstile-key={props.turnstileSiteKey}>
|
||||
SignupForm
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("SignupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(getTranslate).mockResolvedValue((key) => key);
|
||||
});
|
||||
|
||||
it("renders the signup page correctly", async () => {
|
||||
const page = await SignupPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByTestId("signup-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-form")).toHaveAttribute(
|
||||
"data-turnstile-key",
|
||||
"test-turnstile-site-key"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
TERMS_URL,
|
||||
TURNSTILE_SITE_KEY,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
@@ -59,6 +60,7 @@ export const SignupPage = async () => {
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getDefaultEndingCard } from "@/app/lib/templates";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
|
||||
@@ -8,8 +9,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Environment } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon, CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -106,7 +106,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
className="h-full w-full cursor-pointer"
|
||||
id="howToSendCardTrigger">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
@@ -171,23 +171,25 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
|
||||
{localSurvey.type === option.id && option.alert && (
|
||||
<div className="mt-2 flex items-center space-x-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2">
|
||||
<AlertCircleIcon className="h-5 w-5 text-amber-500" />
|
||||
<div className="text-amber-800">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("environments.surveys.edit.formbricks_sdk_is_not_connected")}
|
||||
</p>
|
||||
<p className="text-xs font-normal">
|
||||
<Link
|
||||
href={`/environments/${environment.id}/project/${option.id}-connection`}
|
||||
className="underline hover:text-amber-900"
|
||||
target="_blank">
|
||||
{t("common.connect_formbricks")}
|
||||
</Link>{" "}
|
||||
{t("environments.surveys.edit.and_launch_surveys_in_your_website_or_app")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="warning" className="mt-2">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.edit.formbricks_sdk_is_not_connected")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("common.connect_formbricks") +
|
||||
" " +
|
||||
t("environments.surveys.edit.and_launch_surveys_in_your_website_or_app")}
|
||||
</AlertDescription>
|
||||
<AlertButton
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/environments/${environment.id}/project/${option.id}-connection`,
|
||||
"_blank"
|
||||
)
|
||||
}>
|
||||
{t("common.connect_formbricks")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
|
||||
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Editor } from "@/modules/ui/components/editor";
|
||||
@@ -423,17 +424,13 @@ export const FollowUpModal = ({
|
||||
</Select>
|
||||
|
||||
{triggerType === "endings" && !localSurvey.endings.length ? (
|
||||
<div className="mt-4 flex items-start text-yellow-600">
|
||||
<TriangleAlertIcon
|
||||
className="mr-2 h-5 min-h-5 w-5 min-w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertTitle>
|
||||
{t(
|
||||
"environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -754,7 +751,7 @@ export const FollowUpModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 right-0 z-20 h-12 w-full bg-white p-2">
|
||||
<div className="absolute right-0 bottom-0 z-20 h-12 w-full bg-white p-2">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -15,6 +15,13 @@ vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(() => "Formatted error"),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useSingleUseId", () => {
|
||||
const mockSurvey = {
|
||||
id: "survey123",
|
||||
@@ -40,17 +47,20 @@ describe("useSingleUseId", () => {
|
||||
initialProps: mockSurvey,
|
||||
});
|
||||
|
||||
// Wait for the effect to run
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// Wait for the state to update after the async operation
|
||||
await waitFor(() => {
|
||||
expect(result.current.singleUseId).toBe("mockSingleUseId");
|
||||
});
|
||||
|
||||
expect(generateSingleUseIdAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey123",
|
||||
isEncrypted: true,
|
||||
});
|
||||
expect(result.current.singleUseId).toBe("mockSingleUseId");
|
||||
|
||||
// Re-render with the same props to ensure it doesn't break
|
||||
rerender(mockSurvey);
|
||||
act(() => {
|
||||
rerender(mockSurvey);
|
||||
});
|
||||
|
||||
// The singleUseId remains the same unless we explicitly refresh
|
||||
expect(result.current.singleUseId).toBe("mockSingleUseId");
|
||||
@@ -66,10 +76,11 @@ describe("useSingleUseId", () => {
|
||||
|
||||
const { result } = renderHook(() => useSingleUseId(disabledSurvey));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await waitFor(() => {
|
||||
expect(result.current.singleUseId).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(generateSingleUseIdAction).not.toHaveBeenCalled();
|
||||
expect(result.current.singleUseId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should show toast error if the API call fails", async () => {
|
||||
@@ -77,30 +88,46 @@ describe("useSingleUseId", () => {
|
||||
|
||||
const { result } = renderHook(() => useSingleUseId(mockSurvey));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await waitFor(() => {
|
||||
expect(result.current.singleUseId).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(getFormattedErrorMessage).toHaveBeenCalledWith({ serverError: "Something went wrong" });
|
||||
expect(toast.error).toHaveBeenCalledWith("Formatted error");
|
||||
expect(result.current.singleUseId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should refreshSingleUseId on demand", async () => {
|
||||
// Set up the initial mock response
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "initialId" });
|
||||
|
||||
const { result } = renderHook(() => useSingleUseId(mockSurvey));
|
||||
|
||||
// Wait for initial value to be set
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// We need to wait for the initial async effect to complete
|
||||
// This ensures the hook has time to update state with the first mock value
|
||||
await waitFor(() => {
|
||||
expect(generateSingleUseIdAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(result.current.singleUseId).toBe("initialId");
|
||||
|
||||
// Reset the mock and set up the next response for refreshSingleUseId call
|
||||
vi.mocked(generateSingleUseIdAction).mockClear();
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValueOnce({ data: "refreshedId" });
|
||||
|
||||
// Call refreshSingleUseId and wait for it to complete
|
||||
let refreshedValue;
|
||||
await act(async () => {
|
||||
const val = await result.current.refreshSingleUseId();
|
||||
expect(val).toBe("refreshedId");
|
||||
refreshedValue = await result.current.refreshSingleUseId();
|
||||
});
|
||||
|
||||
// Verify the return value from refreshSingleUseId
|
||||
expect(refreshedValue).toBe("refreshedId");
|
||||
|
||||
// Verify the state was updated
|
||||
expect(result.current.singleUseId).toBe("refreshedId");
|
||||
|
||||
// Verify the API was called with correct parameters
|
||||
expect(generateSingleUseIdAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey123",
|
||||
isEncrypted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "./index";
|
||||
|
||||
describe("Alert", () => {
|
||||
it("renders with default variant", () => {
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
<AlertDescription>Test Description</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with different variants", () => {
|
||||
const variants = ["default", "error", "warning", "info", "success"] as const;
|
||||
|
||||
variants.forEach((variant) => {
|
||||
const { container } = render(
|
||||
<Alert variant={variant}>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass(
|
||||
variant === "default" ? "text-foreground" : `text-${variant}-foreground`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders with different sizes", () => {
|
||||
const sizes = ["default", "small"] as const;
|
||||
|
||||
sizes.forEach((size) => {
|
||||
const { container } = render(
|
||||
<Alert size={size}>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass(size === "default" ? "py-3" : "py-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders with button and handles click", () => {
|
||||
const handleClick = vi.fn();
|
||||
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
<AlertButton onClick={handleClick}>Click me</AlertButton>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
const button = screen.getByText("Click me");
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
const { container } = render(
|
||||
<Alert className="custom-class">
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -21,23 +21,23 @@ const AlertContext = createContext<AlertContextValue>({
|
||||
const useAlertContext = () => useContext(AlertContext);
|
||||
|
||||
// Define alert styles with variants
|
||||
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 [&>svg]:text-foreground", {
|
||||
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-foreground border-border",
|
||||
error:
|
||||
"text-error-foreground border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted",
|
||||
"text-error-foreground [&>svg]:text-error border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted",
|
||||
warning:
|
||||
"text-warning-foreground border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted",
|
||||
info: "text-info-foreground border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted",
|
||||
"text-warning-foreground [&>svg]:text-warning border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted",
|
||||
info: "text-info-foreground [&>svg]:text-info border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted",
|
||||
success:
|
||||
"text-success-foreground border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted",
|
||||
"text-success-foreground [&>svg]:text-success border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"py-3 px-4 text-sm grid grid-cols-[1fr_auto] grid-rows-[auto_auto] gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
"py-3 px-4 text-sm grid grid-cols-[2fr_auto] grid-rows-[auto_auto] gap-y-0.5 gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
small:
|
||||
"px-3 py-2 text-xs flex items-center justify-between gap-2 [&>svg]:flex-shrink-0 [&_button]:text-xs [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -58,7 +58,7 @@ const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => {
|
||||
const variantIcon = variant ? (variant !== "default" ? alertVariantIcons[variant] : null) : null;
|
||||
const variantIcon = variant && variant !== "default" ? alertVariantIcons[variant] : null;
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={{ variant, size }}>
|
||||
@@ -78,8 +78,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"col-start-1 row-start-1 font-medium leading-none tracking-tight",
|
||||
size === "small" ? "min-w-0 flex-shrink truncate" : "col-start-1 row-start-1",
|
||||
"col-start-1 row-start-1 font-medium tracking-tight",
|
||||
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -99,9 +99,7 @@ const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttrib
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_p]:leading-relaxed",
|
||||
size === "small"
|
||||
? "hidden min-w-0 flex-shrink flex-grow truncate opacity-80 sm:block" // Hidden on very small screens, limited width
|
||||
: "col-start-1 row-start-2",
|
||||
size === "small" ? "flex-shrink flex-grow-0 truncate" : "col-start-1 row-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -124,7 +122,7 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={cn(
|
||||
"self-end",
|
||||
alertSize === "small"
|
||||
? "-my-2 -mr-3 ml-auto flex-shrink-0"
|
||||
? "-my-2 -mr-4 ml-auto flex-shrink-0"
|
||||
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
|
||||
)}>
|
||||
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Default, Error, Info, Small, Success, Warning, withButtonAndIcon } from "./stories";
|
||||
|
||||
describe("Alert Stories", () => {
|
||||
const renderStory = (Story: any) => {
|
||||
return render(Story.render(Story.args));
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders Default story", () => {
|
||||
renderStory(Default);
|
||||
expect(screen.getByText("Alert Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is an important notification.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Small story", () => {
|
||||
renderStory(Small);
|
||||
expect(screen.getByText("Information Alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Learn more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders withButtonAndIcon story", () => {
|
||||
renderStory(withButtonAndIcon);
|
||||
expect(screen.getByText("Alert Title")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Learn more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Error story", () => {
|
||||
renderStory(Error);
|
||||
expect(screen.getByText("Error Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Your session has expired. Please log in again.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Log in")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Warning story", () => {
|
||||
renderStory(Warning);
|
||||
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("You are editing sensitive data. Be cautious")).toBeInTheDocument();
|
||||
expect(screen.getByText("Proceed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Info story", () => {
|
||||
renderStory(Info);
|
||||
expect(screen.getByText("Info Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("There was an update to your application.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Refresh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Success story", () => {
|
||||
renderStory(Success);
|
||||
expect(screen.getByText("Success Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("This worked! Please proceed.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { ListItemNode, ListNode } from "@lexical/list";
|
||||
import { TRANSFORMERS } from "@lexical/markdown";
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
|
||||
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
|
||||
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
|
||||
@@ -94,7 +94,7 @@ export const Editor = (props: TextEditorProps) => {
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable style={{ height: props.height }} className="editor-input" />}
|
||||
placeholder={
|
||||
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">{props.placeholder || ""}</div>
|
||||
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">{props.placeholder ?? ""}</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { LightbulbIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
|
||||
@@ -19,19 +20,19 @@ export const EnvironmentNotice = async ({ environmentId, subPageUrl }: Environme
|
||||
const otherEnvironmentId = environments.filter((e) => e.id !== environment.id)[0].id;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex max-w-4xl items-center space-y-4 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
|
||||
<LightbulbIcon className="mr-3 h-4 w-4 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("common.environment_notice", { environment: environment.type })}
|
||||
<a
|
||||
href={`${WEBAPP_URL}/environments/${otherEnvironmentId}${subPageUrl}`}
|
||||
className="ml-1 cursor-pointer text-sm underline">
|
||||
{t("common.switch_to", {
|
||||
environment: environment.type === "production" ? "Development" : "Production",
|
||||
})}
|
||||
.
|
||||
</a>
|
||||
</p>
|
||||
<div>
|
||||
<Alert variant="info" size="small" className="max-w-4xl">
|
||||
<AlertTitle>{t("common.environment_notice", { environment: environment.type })}</AlertTitle>
|
||||
<AlertButton>
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/environments/${otherEnvironmentId}${subPageUrl}`}
|
||||
className="ml-1 cursor-pointer underline">
|
||||
{t("common.switch_to", {
|
||||
environment: environment.type === "production" ? "Development" : "Production",
|
||||
})}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { EnvironmentIdBaseLayout } from "./index";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FORMBRICKS_API_HOST: "test-formbricks-api-host",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "test-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
}));
|
||||
|
||||
// Mock sub-components to render identifiable elements
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
ResponseFilterProvider: ({ children }: any) => <div data-testid="ResponseFilterProvider">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
|
||||
FormbricksClient: ({ userId, email }: any) => (
|
||||
<div data-testid="FormbricksClient">
|
||||
{userId}-{email}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="ToasterClient" />,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
|
||||
PosthogIdentify: ({ organizationId }: any) => <div data-testid="PosthogIdentify">{organizationId}</div>,
|
||||
}));
|
||||
|
||||
describe("EnvironmentIdBaseLayout", () => {
|
||||
it("renders correctly with provided props and children", async () => {
|
||||
const dummySession: Session = { user: { id: "user1" } } as Session;
|
||||
const dummyUser: TUser = { id: "user1", email: "user1@example.com" } as TUser;
|
||||
const dummyOrganization: TOrganization = { id: "org1", name: "Org1", billing: {} } as TOrganization;
|
||||
const dummyChildren = <div data-testid="child">Test Content</div>;
|
||||
|
||||
const result = await EnvironmentIdBaseLayout({
|
||||
environmentId: "env123",
|
||||
session: dummySession,
|
||||
user: dummyUser,
|
||||
organization: dummyOrganization,
|
||||
children: dummyChildren,
|
||||
});
|
||||
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("ResponseFilterProvider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("PosthogIdentify")).toHaveTextContent("org1");
|
||||
expect(screen.getByTestId("FormbricksClient")).toHaveTextContent("user1-user1@example.com");
|
||||
expect(screen.getByTestId("ToasterClient")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
import { Session } from "next-auth";
|
||||
import {
|
||||
FORMBRICKS_API_HOST,
|
||||
FORMBRICKS_ENVIRONMENT_ID,
|
||||
IS_FORMBRICKS_ENABLED,
|
||||
IS_POSTHOG_CONFIGURED,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface EnvironmentIdBaseLayoutProps {
|
||||
children: React.ReactNode;
|
||||
environmentId: string;
|
||||
session: Session;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
}
|
||||
|
||||
export const EnvironmentIdBaseLayout = async ({
|
||||
children,
|
||||
environmentId,
|
||||
session,
|
||||
user,
|
||||
organization,
|
||||
}: EnvironmentIdBaseLayoutProps) => {
|
||||
return (
|
||||
<ResponseFilterProvider>
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
environmentId={environmentId}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
/>
|
||||
<FormbricksClient
|
||||
userId={user.id}
|
||||
email={user.email}
|
||||
formbricksApiHost={FORMBRICKS_API_HOST}
|
||||
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
|
||||
formbricksEnabled={IS_FORMBRICKS_ENABLED}
|
||||
/>
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { checkForYoutubeUrl, convertToEmbedUrl, extractYoutubeId } from "@formbricks/lib/utils/videoUpload";
|
||||
@@ -112,10 +112,9 @@ export const VideoSettings = ({
|
||||
</div>
|
||||
|
||||
{showPlatformWarning && (
|
||||
<div className="flex items-center space-x-2 rounded-md border bg-slate-100 p-2 text-xs text-slate-600">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
<p>{t("environments.surveys.edit.invalid_video_url_warning")}</p>
|
||||
</div>
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertTitle>{t("environments.surveys.edit.invalid_video_url_warning")}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isYoutubeLink && (
|
||||
|
||||
@@ -135,7 +135,7 @@ const nextConfig = {
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value:
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -149,7 +149,7 @@ const nextConfig = {
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value:
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -12,18 +12,17 @@
|
||||
"lint": "next lint",
|
||||
"test": "dotenv -e ../../.env -- vitest run",
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
"generate-api-specs": "tsx ./modules/api/v2/openapi-document.ts > ../../docs/api-v2-reference/openapi.yml",
|
||||
"generate-api-specs": "dotenv -e ../../.env tsx ./modules/api/v2/openapi-document.ts > ../../docs/api-v2-reference/openapi.yml",
|
||||
"merge-client-endpoints": "tsx ./scripts/merge-client-endpoints.ts",
|
||||
"generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/azure": "1.1.9",
|
||||
"@boxyhq/saml-jackson": "1.44.0",
|
||||
"@boxyhq/saml-jackson": "1.45.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/js-core": "workspace:*",
|
||||
@@ -31,16 +30,16 @@
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@hookform/resolvers": "3.9.1",
|
||||
"@hookform/resolvers": "5.0.1",
|
||||
"@intercom/messenger-js-sdk": "0.0.14",
|
||||
"@json2csv/node": "7.0.6",
|
||||
"@lexical/code": "0.21.0",
|
||||
"@lexical/link": "0.21.0",
|
||||
"@lexical/list": "0.21.0",
|
||||
"@lexical/markdown": "0.21.0",
|
||||
"@lexical/react": "0.21.0",
|
||||
"@lexical/rich-text": "0.21.0",
|
||||
"@lexical/table": "0.21.0",
|
||||
"@lexical/code": "0.30.0",
|
||||
"@lexical/link": "0.30.0",
|
||||
"@lexical/list": "0.30.0",
|
||||
"@lexical/markdown": "0.30.0",
|
||||
"@lexical/react": "0.30.0",
|
||||
"@lexical/rich-text": "0.30.0",
|
||||
"@lexical/table": "0.30.0",
|
||||
"@opentelemetry/api-logs": "0.56.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.57.2",
|
||||
"@opentelemetry/host-metrics": "0.35.5",
|
||||
@@ -51,92 +50,92 @@
|
||||
"@opentelemetry/sdk-metrics": "1.30.1",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@prisma/client": "6.0.1",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.3",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.3",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-popover": "1.1.3",
|
||||
"@radix-ui/react-radio-group": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.3",
|
||||
"@radix-ui/react-separator": "1.1.1",
|
||||
"@radix-ui/react-slider": "1.2.2",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-switch": "1.1.2",
|
||||
"@radix-ui/react-tabs": "1.1.2",
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.5",
|
||||
"@react-email/components": "0.0.35",
|
||||
"@sentry/nextjs": "8.52.0",
|
||||
"@tailwindcss/forms": "0.5.9",
|
||||
"@tailwindcss/typography": "0.5.15",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@radix-ui/react-accordion": "1.2.4",
|
||||
"@radix-ui/react-checkbox": "1.1.5",
|
||||
"@radix-ui/react-collapsible": "1.1.4",
|
||||
"@radix-ui/react-dialog": "1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.7",
|
||||
"@radix-ui/react-label": "2.1.3",
|
||||
"@radix-ui/react-popover": "1.1.7",
|
||||
"@radix-ui/react-radio-group": "1.2.4",
|
||||
"@radix-ui/react-select": "2.1.7",
|
||||
"@radix-ui/react-separator": "1.1.3",
|
||||
"@radix-ui/react-slider": "1.2.4",
|
||||
"@radix-ui/react-slot": "1.2.0",
|
||||
"@radix-ui/react-switch": "1.1.4",
|
||||
"@radix-ui/react-tabs": "1.1.4",
|
||||
"@radix-ui/react-toggle": "1.1.3",
|
||||
"@radix-ui/react-toggle-group": "1.1.3",
|
||||
"@radix-ui/react-tooltip": "1.2.0",
|
||||
"@react-email/components": "0.0.36",
|
||||
"@sentry/nextjs": "9.12.0",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tanstack/react-table": "8.21.2",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@tolgee/cli": "2.8.1",
|
||||
"@tolgee/format-icu": "6.0.1",
|
||||
"@tolgee/react": "6.0.1",
|
||||
"@tolgee/cli": "2.10.2",
|
||||
"@tolgee/format-icu": "6.2.4",
|
||||
"@tolgee/react": "6.2.4",
|
||||
"@unkey/ratelimit": "0.5.5",
|
||||
"@vercel/functions": "1.5.2",
|
||||
"@vercel/og": "0.6.4",
|
||||
"@vercel/otel": "1.10.0",
|
||||
"ai": "4.1.17",
|
||||
"autoprefixer": "10.4.20",
|
||||
"bcryptjs": "2.4.3",
|
||||
"@vercel/functions": "2.0.0",
|
||||
"@vercel/og": "0.6.8",
|
||||
"@vercel/otel": "1.10.4",
|
||||
"ai": "4.3.4",
|
||||
"autoprefixer": "10.4.21",
|
||||
"bcryptjs": "3.0.2",
|
||||
"boring-avatars": "1.11.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"cmdk": "1.1.1",
|
||||
"csv-parse": "5.6.0",
|
||||
"dotenv": "16.4.7",
|
||||
"encoding": "0.1.13",
|
||||
"file-loader": "6.2.0",
|
||||
"framer-motion": "11.15.0",
|
||||
"googleapis": "144.0.0",
|
||||
"framer-motion": "12.6.3",
|
||||
"googleapis": "148.0.0",
|
||||
"heic-convert": "2.1.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"jiti": "2.4.1",
|
||||
"jiti": "2.4.2",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"langfuse-vercel": "3.31.3",
|
||||
"lexical": "0.21.0",
|
||||
"langfuse-vercel": "3.37.1",
|
||||
"lexical": "0.30.0",
|
||||
"lodash": "4.17.21",
|
||||
"lru-cache": "11.0.2",
|
||||
"lucide-react": "0.468.0",
|
||||
"mime": "4.0.4",
|
||||
"next": "15.2.4",
|
||||
"lru-cache": "11.1.0",
|
||||
"lucide-react": "0.487.0",
|
||||
"mime": "4.0.7",
|
||||
"next": "15.2.5",
|
||||
"next-auth": "4.24.11",
|
||||
"next-safe-action": "7.10.2",
|
||||
"next-safe-action": "7.10.5",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.16",
|
||||
"nodemailer": "6.10.0",
|
||||
"opentelemetry": "0.1.0",
|
||||
"optional": "0.1.4",
|
||||
"otplib": "12.0.1",
|
||||
"papaparse": "5.4.1",
|
||||
"papaparse": "5.5.2",
|
||||
"postcss": "8.5.3",
|
||||
"posthog-js": "1.200.2",
|
||||
"posthog-js": "1.235.0",
|
||||
"prismjs": "1.30.0",
|
||||
"qr-code-styling": "1.9.1",
|
||||
"react": "19.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-confetti": "6.1.0",
|
||||
"react-day-picker": "9.6.3",
|
||||
"react-dom": "19.0.0",
|
||||
"react-hook-form": "7.54.1",
|
||||
"react-hot-toast": "2.4.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-markdown": "9.0.3",
|
||||
"react-confetti": "6.4.0",
|
||||
"react-day-picker": "9.6.5",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-hot-toast": "2.5.2",
|
||||
"react-icons": "5.5.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-radio-group": "3.0.3",
|
||||
"react-turnstile": "1.1.4",
|
||||
"react-use": "17.6.0",
|
||||
"redis": "4.7.0",
|
||||
"sharp": "0.33.5",
|
||||
"sharp": "0.34.1",
|
||||
"stripe": "16.7.0",
|
||||
"tailwind-merge": "3.1.0",
|
||||
"tailwind-merge": "3.2.0",
|
||||
"tailwindcss": "3.4.16",
|
||||
"ua-parser-js": "2.0.0",
|
||||
"ua-parser-js": "2.0.3",
|
||||
"uuid": "11.1.0",
|
||||
"webpack": "5.97.1",
|
||||
"webpack": "5.99.5",
|
||||
"xlsx": "0.18.5",
|
||||
"zod": "3.24.1",
|
||||
"zod-openapi": "4.2.4"
|
||||
@@ -155,7 +154,7 @@
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/testing-library__react": "10.2.0",
|
||||
"@vitest/coverage-v8": "3.1.1",
|
||||
"vite": "6.2.4",
|
||||
"vite": "6.2.5",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.1.1",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user