Compare commits

...

34 Commits

Author SHA1 Message Date
Dhruwang Jariwala
c533f37983 chore: improve accessibility for matrix question (#5320)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-12 04:40:24 +00:00
Anshuman Pandey
ca4f8385e4 fix: adds FormbricksEnvironment struct for url constants (#5312) 2025-04-11 13:44:25 +00:00
Matti Nannt
3eb9aa74ed chore: upgrade typescript and react dependencies (#5317) 2025-04-11 13:01:54 +02:00
Piyush Gupta
637b51464c docs: updates the API keys docs in API reference (#5319) 2025-04-11 08:46:04 +00:00
Dhruwang Jariwala
fd9585a66e fix: respondent should not see redirect card text (#5239)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-11 04:22:25 +00:00
Matti Nannt
49ecbcb0c9 fix: updatedAt not set in response update (#5315) 2025-04-10 11:04:42 +00:00
Piyush Gupta
1132bdd66a fix: openAPI spec for contact endpoints (#5247)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-10 10:22:40 +00:00
Anshuman Pandey
c7d6ed9ea3 chore: removes api package and deps (#5251)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-10 09:41:39 +00:00
Matti Nannt
782528f169 chore: update surveys package npm dependencies (#5302) 2025-04-10 10:44:56 +02:00
Piyush Gupta
104c78275f docs: fixes framework guide link (#5307) 2025-04-10 08:11:40 +00:00
Matti Nannt
d9d88f7175 chore: update eslint npm dependencies (#5313) 2025-04-10 10:22:58 +02:00
Dhruwang Jariwala
bf7e24cf11 fix: stripe issue for customers with existing stripe ID (#5308) 2025-04-10 07:56:01 +00:00
Anshuman Pandey
c8aba01db3 fix: adds isWebEnvironment check in the surveys package (#5310) 2025-04-10 09:01:36 +02:00
Piyush Gupta
a896c7e46e docs: updated API playground link in the webhooks docs (#5301) 2025-04-09 08:33:36 +00:00
Matti Nannt
8018ec14a2 chore: use remote turbocache for building formbricks (#5305) 2025-04-09 10:38:17 +02:00
victorvhs017
9c3208c860 chore: Refactored the Formbricks next public env variables and added test files (#5014)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 08:10:32 +00:00
Anshuman Pandey
e1063964cf fix: fixes segment self referencing issue (#5254)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 06:58:28 +00:00
victorvhs017
38568738cc feat: Added test configuration and initial test files to the surveys package (#5253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-09 06:53:16 +00:00
Piyush Gupta
15b8358b14 fix: date format in response table (#5304) 2025-04-09 05:39:57 +00:00
Anshuman Pandey
2173cb2610 fix: removes sourcemaps (#5257) 2025-04-09 04:50:56 +00:00
Matti Nannt
87b925d622 chore: update apps/web npm dependencies (#5300) 2025-04-09 06:58:53 +02:00
Piyush Gupta
885b06cc26 fix: adds date value check in date question summary (#5296) 2025-04-09 04:07:39 +00:00
Matti Nannt
adb6a5f41e chore: upgrade npm dependencies (#5299) 2025-04-09 04:47:07 +02:00
Matti Nannt
3b815e22e3 chore: add docker build check github action (#4875)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-08 13:26:48 +00:00
Matti Nannt
4d4a5c0e64 fix: solve sonarqube security hotspots (#5292) 2025-04-08 14:58:24 +02:00
Anshuman Pandey
0e89293974 fix: appUrl fix in iOS and android packages (#5295) 2025-04-08 14:51:30 +02:00
Jakob Schott
c306911b3a fix: replace hard-coded alerts with alert component (#5156)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-08 10:26:28 +00:00
Piyush Gupta
4f276f0095 feat: personalized survey links for segment of users endpoint (#5032)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-08 05:54:27 +00:00
Dhruwang Jariwala
81fc97c7e9 fix: Add Cache-Control to allowed CORS headers (#5252) 2025-04-07 14:47:02 +00:00
Matti Nannt
785c5a59c6 chore: make mock passwords more obvious to test suites (#5240) 2025-04-07 12:40:40 +00:00
Piyush Gupta
25ecfaa883 fix: formbricks version on localhost (#5250) 2025-04-07 10:42:13 +00:00
Anshuman Pandey
38e2c019fa fix: ios package sonarqube fixes (#5249) 2025-04-07 08:48:56 +00:00
victorvhs017
15878a4ac5 chore: Refactored the Turnstile next public env variable and added test files (#4997)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-07 06:07:39 +00:00
Matti Nannt
9802536ded chore: upgrade demo app to tailwind v4 (#5237)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-07 05:40:10 +00:00
215 changed files with 11034 additions and 7278 deletions

View File

@@ -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=

View File

@@ -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 }}

View File

@@ -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 }}

View 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!"

View File

@@ -16,6 +16,8 @@ on:
env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write

View File

@@ -1,4 +1,8 @@
{
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",
"projectKey": "formbricks_formbricks"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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:*",

View File

@@ -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&apos;re connected with env:</p>
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;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}

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
};

View File

@@ -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")],
};

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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>
);
};

View File

@@ -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();

View File

@@ -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;
};

View File

@@ -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}

View File

@@ -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"

View File

@@ -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";

View File

@@ -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");
});
});

View File

@@ -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>
);
};

View File

@@ -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", () => ({

View File

@@ -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>
);

View File

@@ -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)}

View File

@@ -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", () => ({

View File

@@ -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}

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
export { GET };

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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}</>;

View File

@@ -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>
);

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),

View File

@@ -0,0 +1,6 @@
export const managementServer = [
{
url: `https://app.formbricks.com/api/v2/management`,
description: "Formbricks Management API",
},
];

View File

@@ -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,

View File

@@ -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(),
}),
})
),
},
},
},
},
};

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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)],
}
)()
);

View File

@@ -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)],
}
)()
);

View File

@@ -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)),
},
},
},
},
};

View File

@@ -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)],
}
)()
);

View File

@@ -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)],
}
)()
);

View File

@@ -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 }],
});
}
});
});

View File

@@ -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",
});
}
});
});

View File

@@ -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}`],
});
});
});

View File

@@ -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);
});
});

View File

@@ -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,
});
},
});

View File

@@ -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"),
});

View File

@@ -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,
},
};

View File

@@ -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,
},
};

View File

@@ -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),

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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",
},
];

View File

@@ -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>;

View File

@@ -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}>

View 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");
});
});

View File

@@ -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);

View File

@@ -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" } });

View File

@@ -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>

View File

@@ -54,6 +54,9 @@ export const createSubscription = async (
payment_method_data: { allow_redisplay: "always" },
...(!isNewOrganization && {
customer: organization.billing.stripeCustomerId ?? undefined,
customer_update: {
name: "auto",
},
}),
};

View File

@@ -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,
},
};

View File

@@ -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);

View File

@@ -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,
}),
]),
}),
})
);
});
});
});

View File

@@ -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;
}

View File

@@ -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}

View 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

View 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);
}
}
};

View 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);
});
});

View File

@@ -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>

View File

@@ -14,8 +14,7 @@ interface TwoFactorBackupProps {
totpCode?: string | undefined;
backupCode?: string | undefined;
},
any,
undefined
any
>;
}

View File

@@ -13,8 +13,7 @@ interface TwoFactorProps {
totpCode?: string | undefined;
backupCode?: string | undefined;
},
any,
undefined
any
>;
}

View 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");
});
});
});

View File

@@ -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 };
};

View File

@@ -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">

View File

@@ -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>

View 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"
);
});
});

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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"

View File

@@ -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,
});
});
});

View File

@@ -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");
});
});

View File

@@ -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}>

View File

@@ -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();
});
});

View File

@@ -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}
/>

View File

@@ -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>
);
};

View File

@@ -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");
});
});

View File

@@ -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>
);
};

View File

@@ -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 && (

View File

@@ -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",
},
],
},

View File

@@ -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