mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-13 20:11:43 -05:00
Compare commits
12 Commits
fix-user-i
...
fix/sonarq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7540c64fdf | ||
|
|
3b815e22e3 | ||
|
|
4d4a5c0e64 | ||
|
|
0e89293974 | ||
|
|
c306911b3a | ||
|
|
4f276f0095 | ||
|
|
81fc97c7e9 | ||
|
|
785c5a59c6 | ||
|
|
25ecfaa883 | ||
|
|
38e2c019fa | ||
|
|
15878a4ac5 | ||
|
|
9802536ded |
@@ -117,7 +117,7 @@ IMPRINT_URL=
|
||||
IMPRINT_ADDRESS=
|
||||
|
||||
# Configure Turnstile in signup flow
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Configure Github Login
|
||||
|
||||
163
.github/workflows/docker-build-validation.yml
vendored
Normal file
163
.github/workflows/docker-build-validation.yml
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
name: Docker Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-docker-build:
|
||||
name: Validate Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Add PostgreSQL service container
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: formbricks
|
||||
ports:
|
||||
- 5432:5432
|
||||
# Health check to ensure PostgreSQL is ready before using it
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: formbricks-test:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Verify PostgreSQL Connection
|
||||
run: |
|
||||
echo "Verifying PostgreSQL connection..."
|
||||
# Install PostgreSQL client to test connection
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
|
||||
# Test connection using psql
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
||||
|
||||
# Show network configuration
|
||||
echo "Network configuration:"
|
||||
ip addr show
|
||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||
|
||||
- name: Test Docker Image with Health Check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🧪 Testing if the Docker image starts correctly..."
|
||||
|
||||
# Add extra docker run args to support host.docker.internal on Linux
|
||||
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
|
||||
|
||||
# Start the container with host.docker.internal pointing to the host
|
||||
docker run --name formbricks-test \
|
||||
$DOCKER_RUN_ARGS \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||
-d formbricks-test:${{ github.sha }}
|
||||
|
||||
# Give it more time to start up
|
||||
echo "Waiting 45 seconds for application to start..."
|
||||
sleep 45
|
||||
|
||||
# Check if the container is running
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
||||
echo "❌ Container failed to start properly!"
|
||||
docker logs formbricks-test
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Container started successfully!"
|
||||
fi
|
||||
|
||||
# Try connecting to PostgreSQL from inside the container
|
||||
echo "Testing PostgreSQL connection from inside container..."
|
||||
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
||||
|
||||
# Try to access the health endpoint
|
||||
echo "🏥 Testing /health endpoint..."
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
HEALTH_CHECK_SUCCESS=false
|
||||
|
||||
set +e # Disable exit on error to allow for retries
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
# Show container logs before each attempt to help debugging
|
||||
if [ $RETRY_COUNT -gt 1 ]; then
|
||||
echo "📋 Current container logs:"
|
||||
docker logs --tail 20 formbricks-test
|
||||
fi
|
||||
|
||||
# Get detailed curl output for debugging
|
||||
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
||||
CURL_EXIT_CODE=$?
|
||||
|
||||
echo "Curl exit code: $CURL_EXIT_CODE"
|
||||
echo "Curl output: $HTTP_OUTPUT"
|
||||
|
||||
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
||||
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
||||
echo "Status code detected: $STATUS_CODE"
|
||||
|
||||
if [ "$STATUS_CODE" = "200" ]; then
|
||||
echo "✅ Health check successful!"
|
||||
HEALTH_CHECK_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
||||
fi
|
||||
else
|
||||
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
||||
fi
|
||||
|
||||
echo "Waiting 15 seconds before next attempt..."
|
||||
sleep 15
|
||||
done
|
||||
|
||||
# Show full container logs for debugging
|
||||
echo "📋 Full container logs:"
|
||||
docker logs formbricks-test
|
||||
|
||||
# Clean up the container
|
||||
echo "🧹 Cleaning up..."
|
||||
docker rm -f formbricks-test
|
||||
|
||||
# Exit with failure if health check did not succeed
|
||||
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✨ Docker validation complete - all checks passed!"
|
||||
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
permissions:
|
||||
|
||||
@@ -27,7 +27,7 @@ const secondaryNavigation = [
|
||||
|
||||
export function Sidebar(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
|
||||
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
|
||||
<nav
|
||||
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
|
||||
aria-label="Sidebar">
|
||||
@@ -41,7 +41,7 @@ export function Sidebar(): React.JSX.Element {
|
||||
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
|
||||
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
"tailwindcss": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function AppPage(): React.JSX.Element {
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
|
||||
</p>
|
||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
|
||||
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
|
||||
|
||||
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
|
||||
<p className="mb-1 sm:mb-0 sm:mr-2">You're connected with env:</p>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
||||
@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
@@ -40,8 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
|
||||
chmod +x /tmp/read-secrets.sh
|
||||
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
@@ -87,31 +85,60 @@ RUN apk add --no-cache curl \
|
||||
|
||||
WORKDIR /home/nextjs
|
||||
|
||||
COPY --from=installer /app/apps/web/next.config.mjs .
|
||||
COPY --from=installer /app/apps/web/package.json .
|
||||
# Leverage output traces to reduce image size
|
||||
|
||||
# Ensure no write permissions are assigned to the copied resources
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||
RUN chmod -R 755 ./
|
||||
|
||||
COPY --from=installer /app/apps/web/next.config.mjs .
|
||||
RUN chmod 644 ./next.config.mjs
|
||||
|
||||
COPY --from=installer /app/apps/web/package.json .
|
||||
RUN chmod 644 ./package.json
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
RUN chmod -R 755 ./apps/web/.next/static
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
|
||||
RUN chmod -R 755 ./apps/web/public
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
|
||||
RUN chmod -R 755 ./packages/database/migration
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
|
||||
RUN chmod -R 755 ./packages/database/src
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
|
||||
RUN chmod -R 755 ./packages/database/node_modules
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||
RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
|
||||
|
||||
# Copy Prisma-specific generated files
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
RUN chmod -R 755 ./node_modules/@prisma/client
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
RUN chmod -R 755 ./node_modules/.prisma
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
|
||||
COPY /docker/cronjobs /app/docker/cronjobs
|
||||
RUN chmod 644 ./prisma_version.txt
|
||||
|
||||
COPY /docker/cronjobs /app/docker/cronjobs
|
||||
RUN chmod -R 755 /app/docker/cronjobs
|
||||
|
||||
# Copy required dependencies
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g tsx typescript prisma pino-pretty
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membershipRole}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isLicenseActive={active}
|
||||
|
||||
@@ -63,6 +63,7 @@ interface NavigationProps {
|
||||
projects: TProject[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
@@ -79,6 +80,7 @@ export const MainNavigation = ({
|
||||
isFormbricksCloud,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isDevelopment,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -296,7 +298,7 @@ export const MainNavigation = ({
|
||||
|
||||
<div>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// PosthogIdentify.test.tsx
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
|
||||
|
||||
export { GET };
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getContactAttributeKeys = reactCache((environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const contactAttributeKeys = await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
const keys = contactAttributeKeys.map((key) => key.key);
|
||||
return ok(keys);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "contact attribute keys", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`getContactAttributeKeys-contact-links-${environmentId}`],
|
||||
{
|
||||
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
|
||||
import { getSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment";
|
||||
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys";
|
||||
import { TContactWithAttributes } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getContactsInSegment = reactCache(
|
||||
(surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<ApiResponseWithMeta<TContactWithAttributes[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const surveyResult = await getSurvey(surveyId);
|
||||
if (!surveyResult.ok) {
|
||||
return err(surveyResult.error);
|
||||
}
|
||||
|
||||
const survey = surveyResult.data;
|
||||
|
||||
if (survey.type !== "link" || survey.status !== "inProgress") {
|
||||
logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress");
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
return err(error);
|
||||
}
|
||||
|
||||
const segmentResult = await getSegment(segmentId);
|
||||
if (!segmentResult.ok) {
|
||||
return err(segmentResult.error);
|
||||
}
|
||||
|
||||
const segment = segmentResult.data;
|
||||
|
||||
if (survey.environmentId !== segment.environmentId) {
|
||||
logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment");
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "bad_request",
|
||||
details: [{ field: "segmentId", issue: "Environment mismatch" }],
|
||||
};
|
||||
return err(error);
|
||||
}
|
||||
|
||||
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
|
||||
segment.id,
|
||||
segment.filters,
|
||||
segment.environmentId
|
||||
);
|
||||
|
||||
if (!segmentFilterToPrismaQueryResult.ok) {
|
||||
return err(segmentFilterToPrismaQueryResult.error);
|
||||
}
|
||||
|
||||
const { whereClause } = segmentFilterToPrismaQueryResult.data;
|
||||
|
||||
const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId);
|
||||
if (!contactAttributeKeysResult.ok) {
|
||||
return err(contactAttributeKeysResult.error);
|
||||
}
|
||||
|
||||
const allAttributeKeys = contactAttributeKeysResult.data;
|
||||
|
||||
const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim());
|
||||
const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field));
|
||||
|
||||
const allowedAttributes = attributesToInclude.slice(0, 20);
|
||||
|
||||
const [totalContacts, contacts] = await prisma.$transaction([
|
||||
prisma.contact.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
|
||||
prisma.contact.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: allowedAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
skip: skip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const contactsWithAttributes = contacts.map((contact) => {
|
||||
const attributes = contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
return {
|
||||
contactId: contact.id,
|
||||
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return ok({
|
||||
data: contactsWithAttributes,
|
||||
meta: {
|
||||
total: totalContacts,
|
||||
limit: limit,
|
||||
offset: skip,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment");
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
};
|
||||
return err(apiError);
|
||||
}
|
||||
},
|
||||
[`getContactsInSegment-${surveyId}-${segmentId}-${attributeKeys}-${limit}-${skip}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byId(segmentId), surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
ZContactLinkResponse,
|
||||
ZContactLinksBySegmentParams,
|
||||
ZContactLinksBySegmentQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
|
||||
export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactLinksBySegment",
|
||||
summary: "Get survey links for contacts in a segment",
|
||||
description: "Generates personalized survey links for contacts in a segment.",
|
||||
tags: ["Management API > Surveys > Contact Links"],
|
||||
requestParams: {
|
||||
path: ZContactLinksBySegmentParams,
|
||||
query: ZContactLinksBySegmentQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact links generated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZContactLinkResponse))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Segment } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getSegment = reactCache(async (segmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Pick<Segment, "id" | "environmentId" | "filters">, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const segment = await prisma.segment.findUnique({
|
||||
where: { id: segmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(segment);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`contact-link-getSegment-${segmentId}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byId(segmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Survey } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getSurvey = reactCache(async (surveyId: string) =>
|
||||
cache(
|
||||
async (): Promise<
|
||||
Result<Pick<Survey, "id" | "environmentId" | "type" | "status">, ApiErrorResponseV2>
|
||||
> => {
|
||||
try {
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!survey) {
|
||||
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(survey);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`contact-link-getSurvey-${surveyId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getContactAttributeKeys", () => {
|
||||
const mockEnvironmentId = "mock-env-123";
|
||||
const mockContactAttributeKeys = [{ key: "email" }, { key: "name" }, { key: "userId" }];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("successfully retrieves contact attribute keys", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockContactAttributeKeys);
|
||||
|
||||
const result = await getContactAttributeKeys(mockEnvironmentId);
|
||||
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: mockEnvironmentId },
|
||||
select: { key: true },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(["email", "name", "userId"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles database error gracefully", async () => {
|
||||
const mockError = new Error("Database error");
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(mockError);
|
||||
|
||||
const result = await getContactAttributeKeys(mockEnvironmentId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "contact attribute keys", issue: mockError.message }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { SurveyStatus, SurveyType } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { TBaseFilters } from "@formbricks/types/segment";
|
||||
import { getContactsInSegment } from "../contact";
|
||||
import { getSegment } from "../segment";
|
||||
import { getSurvey } from "../surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../segment", () => ({
|
||||
getSegment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../surveys", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getContactsInSegment", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockSegmentId = "segment-456";
|
||||
const mockLimit = 10;
|
||||
const mockSkip = 0;
|
||||
const mockEnvironmentId = "env-789";
|
||||
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
type: "link" as SurveyType,
|
||||
status: "inProgress" as SurveyStatus,
|
||||
};
|
||||
|
||||
// Define filters as a TBaseFilters array with correct structure
|
||||
const mockFilters: TBaseFilters = [
|
||||
{
|
||||
id: "filter-1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "resource-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
value: "test@example.com",
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockSegment = {
|
||||
id: mockSegmentId,
|
||||
environmentId: mockEnvironmentId,
|
||||
filters: mockFilters,
|
||||
};
|
||||
|
||||
const mockContacts = [
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "another@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Another User" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSurvey,
|
||||
});
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSegment,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([{ key: "email" }, { key: "name" }]);
|
||||
|
||||
vi.mocked(prisma.contact.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return contacts when all operations succeed", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
|
||||
const attributeKeys = "email,name";
|
||||
const result = await getContactsInSegment(
|
||||
mockSurveyId,
|
||||
mockSegmentId,
|
||||
mockLimit,
|
||||
mockSkip,
|
||||
attributeKeys
|
||||
);
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{
|
||||
environmentId: "env-789",
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
},
|
||||
value: { equals: "test@example.com", mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.contact.count).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
});
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["email", "name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: mockLimit,
|
||||
skip: mockSkip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should filter contact attributes when fields parameter is provided", async () => {
|
||||
const filteredMockContacts = [
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [{ attributeKey: { key: "email" }, value: "another@example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([filteredMockContacts.length, filteredMockContacts]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email");
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{
|
||||
environmentId: "env-789",
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
},
|
||||
value: { equals: "test@example.com", mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
|
||||
expect(prisma.contact.count).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
});
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["email"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: mockLimit,
|
||||
skip: mockSkip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle multiple fields when fields parameter has comma-separated values", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email,name");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return no attributes but still return contacts when fields parameter is empty", async () => {
|
||||
const mockContactsWithoutAttributes = mockContacts.map((contact) => ({
|
||||
...contact,
|
||||
attributes: [],
|
||||
}));
|
||||
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([
|
||||
mockContactsWithoutAttributes.length,
|
||||
mockContactsWithoutAttributes,
|
||||
]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: mockContacts.map((contact) => ({
|
||||
contactId: contact.id,
|
||||
})),
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not a link survey", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
...mockSurvey,
|
||||
type: "web" as SurveyType,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not active", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
...mockSurvey,
|
||||
status: "completed" as SurveyStatus,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not found", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: false,
|
||||
error: surveyError,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when segment is not found", async () => {
|
||||
const segmentError: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "segment", issue: "not found" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: false,
|
||||
error: segmentError,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).not.toHaveBeenCalled();
|
||||
expect(prisma.contact.findMany).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(segmentError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey and segment are in different environments", async () => {
|
||||
const mockSegmentWithDifferentEnv = {
|
||||
...mockSegment,
|
||||
environmentId: "different-env",
|
||||
};
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSegmentWithDifferentEnv,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).not.toHaveBeenCalled();
|
||||
expect(prisma.contact.findMany).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
details: [{ field: "segmentId", issue: "Environment mismatch" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when database operation fails", async () => {
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.contact.count).mockRejectedValue(dbError);
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Segment } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { getSegment } from "../segment";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
segment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache/segment", () => ({
|
||||
segmentCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `segment-${id}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getSegment", () => {
|
||||
const mockSegmentId = "segment-123";
|
||||
const mockSegment: Pick<Segment, "id" | "environmentId" | "filters"> = {
|
||||
id: mockSegmentId,
|
||||
environmentId: "env-123",
|
||||
filters: [
|
||||
{
|
||||
id: "filter-123",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "attr_1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
value: "test@example.com",
|
||||
qualifier: { operator: "equals" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return segment data when segment is found", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSegmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockSegment);
|
||||
}
|
||||
|
||||
expect(segmentCache.tag.byId).toHaveBeenCalledWith(mockSegmentId);
|
||||
});
|
||||
|
||||
test("should return not_found error when segment doesn't exist", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSegmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "segment", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error when database throws an error", async () => {
|
||||
const mockError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.segment.findUnique).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "segment", issue: "Database connection failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should use correct cache key", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
|
||||
|
||||
await getSegment(mockSegmentId);
|
||||
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSegment-${mockSegmentId}`], {
|
||||
tags: [`segment-${mockSegmentId}`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSurvey } from "../surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/survey/cache", () => ({
|
||||
surveyCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `survey-${id}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getSurvey", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockEnvironmentId = "env-456";
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return survey data when survey is found", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSurveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockSurvey);
|
||||
}
|
||||
|
||||
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
|
||||
tags: [`survey-${mockSurveyId}`],
|
||||
});
|
||||
});
|
||||
|
||||
test("should return not_found error when survey doesn't exist", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSurveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error when database throws an error", async () => {
|
||||
const mockError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "survey", issue: "Database connection failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should use correct cache key and tags", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
|
||||
|
||||
await getSurvey(mockSurveyId);
|
||||
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
|
||||
tags: [`survey-${mockSurveyId}`],
|
||||
});
|
||||
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
|
||||
import {
|
||||
ZContactLinksBySegmentParams,
|
||||
ZContactLinksBySegmentQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ surveyId: string; segmentId: string }> }
|
||||
) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: ZContactLinksBySegmentParams,
|
||||
query: ZContactLinksBySegmentQuery,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params, query } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return handleApiError(request, {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
// Get contacts based on segment
|
||||
const contactsResult = await getContactsInSegment(
|
||||
params.surveyId,
|
||||
params.segmentId,
|
||||
query?.limit || 10,
|
||||
query?.skip || 0,
|
||||
query?.attributeKeys
|
||||
);
|
||||
|
||||
if (!contactsResult.ok) {
|
||||
return handleApiError(request, contactsResult.error);
|
||||
}
|
||||
|
||||
const { data: contacts, meta } = contactsResult.data;
|
||||
|
||||
// Calculate expiration date based on expirationDays
|
||||
let expiresAt: string | null = null;
|
||||
if (query?.expirationDays) {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
|
||||
expiresAt = expirationDate.toISOString();
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contacts
|
||||
.map((contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(
|
||||
contactId,
|
||||
params.surveyId,
|
||||
query?.expirationDays || undefined
|
||||
);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
logger.error(
|
||||
{ error: surveyUrlResult.error, contactId: contactId, surveyId: params.surveyId },
|
||||
"Failed to generate survey URL for contact"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
contactId,
|
||||
attributes,
|
||||
surveyUrl: surveyUrlResult.data,
|
||||
expiresAt,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return responses.successResponse({
|
||||
data: contactLinks,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZContactLinksBySegmentParams = z.object({
|
||||
surveyId: z.string().cuid2().describe("The ID of the survey"),
|
||||
segmentId: z.string().cuid2().describe("The ID of the segment"),
|
||||
});
|
||||
|
||||
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
|
||||
limit: true,
|
||||
skip: true,
|
||||
}).extend({
|
||||
expirationDays: z.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.nullish()
|
||||
.default(null)
|
||||
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
|
||||
attributeKeys: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated list of contact attribute keys to include in the response. You can have max 20 keys. If not provided, no attributes will be included."
|
||||
)
|
||||
.refine((fields) => {
|
||||
if (!fields) return true;
|
||||
const fieldsArray = fields.split(",");
|
||||
return fieldsArray.length <= 20;
|
||||
}, "You can have max 20 keys."),
|
||||
});
|
||||
|
||||
export type TContactWithAttributes = {
|
||||
contactId: string;
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const ZContactLinkResponse = z.object({
|
||||
contactId: z.string().describe("The ID of the contact"),
|
||||
surveyUrl: z.string().url().describe("Personalized survey link"),
|
||||
expiresAt: z.string().nullable().describe("The date and time the link expires, null if no expiration"),
|
||||
attributes: z.record(z.string(), z.string()).describe("The attributes of the contact"),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
|
||||
import { ZodOpenApiPathsObject } from "zod-openapi";
|
||||
|
||||
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
|
||||
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
|
||||
get: getContactLinksBySegmentEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-at
|
||||
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";
|
||||
@@ -43,6 +44,7 @@ const document = createDocument({
|
||||
...contactAttributePaths,
|
||||
...contactAttributeKeyPaths,
|
||||
...surveyPaths,
|
||||
...surveyContactLinksBySegmentPaths,
|
||||
...webhookPaths,
|
||||
...teamPaths,
|
||||
...projectTeamPaths,
|
||||
@@ -83,6 +85,10 @@ const document = createDocument({
|
||||
name: "Management API > Surveys",
|
||||
description: "Operations for managing surveys.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Surveys > Contact Links",
|
||||
description: "Operations for generating personalized survey links for contacts.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Webhooks",
|
||||
description: "Operations for managing webhooks.",
|
||||
|
||||
@@ -1,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>;
|
||||
|
||||
377
apps/web/modules/auth/signup/components/signup-form.test.tsx
Normal file
377
apps/web/modules/auth/signup/components/signup-form.test.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmailTokenAction } from "../../../auth/actions";
|
||||
import { SignupForm } from "./signup-form";
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
}));
|
||||
|
||||
// Set up a push mock for useRouter
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
useSearchParams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-turnstile", () => ({
|
||||
useTurnstile: () => ({
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
default: (props: any) => (
|
||||
<div
|
||||
data-testid="turnstile"
|
||||
onClick={() => {
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess("test-turnstile-token");
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/signup/actions", () => ({
|
||||
createUserAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../auth/actions", () => ({
|
||||
createEmailTokenAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
|
||||
vi.mock("@/modules/ee/sso/components/sso-options", () => ({
|
||||
SSOOptions: () => <div data-testid="sso-options">SSOOptions</div>,
|
||||
}));
|
||||
vi.mock("@/modules/auth/signup/components/terms-privacy-links", () => ({
|
||||
TermsPrivacyLinks: () => <div data-testid="terms-privacy-links">TermsPrivacyLinks</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: (props: any) => <button {...props}>{props.children}</button>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: (props: any) => <input {...props} />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/password-input", () => ({
|
||||
PasswordInput: (props: any) => <input type="password" {...props} />,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
webAppUrl: "http://localhost",
|
||||
privacyUrl: "http://localhost/privacy",
|
||||
termsUrl: "http://localhost/terms",
|
||||
emailAuthEnabled: true,
|
||||
googleOAuthEnabled: false,
|
||||
githubOAuthEnabled: false,
|
||||
azureOAuthEnabled: false,
|
||||
oidcOAuthEnabled: false,
|
||||
userLocale: "en-US",
|
||||
emailVerificationDisabled: false,
|
||||
isSsoEnabled: false,
|
||||
samlSsoEnabled: false,
|
||||
isTurnstileConfigured: false,
|
||||
samlTenant: "",
|
||||
samlProduct: "",
|
||||
defaultOrganizationId: "org1",
|
||||
defaultOrganizationRole: "member",
|
||||
turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false
|
||||
} as const;
|
||||
|
||||
describe("SignupForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("toggles the signup form on button click", () => {
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Initially, the signup form is hidden.
|
||||
try {
|
||||
screen.getByTestId("signup-name");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
// Click the button to reveal the signup form.
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Now the input fields should appear.
|
||||
expect(screen.getByTestId("signup-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-email")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-password")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the form successfully", async () => {
|
||||
// Set up mocks for the API actions.
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Click the button to reveal the signup form.
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
const nameInput = screen.getByTestId("signup-name");
|
||||
const emailInput = screen.getByTestId("signup-email");
|
||||
const passwordInput = screen.getByTestId("signup-password");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "Test User" } });
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "Password123" } });
|
||||
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
// Since email verification is enabled (emailVerificationDisabled is false),
|
||||
// router.push should be called with the verification URL.
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
|
||||
});
|
||||
|
||||
it("submits the form successfully when turnstile is configured", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Simulate receiving a turnstile token by clicking the Turnstile element.
|
||||
const turnstileElement = screen.getByTestId("turnstile");
|
||||
fireEvent.click(turnstileElement);
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success");
|
||||
});
|
||||
|
||||
it("submits the form successfully when turnstile is configured, but createEmailTokenAction don't return data", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue(undefined);
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValue("error");
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Simulate receiving a turnstile token by clicking the Turnstile element.
|
||||
const turnstileElement = screen.getByTestId("turnstile");
|
||||
fireEvent.click(turnstileElement);
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
|
||||
// Since Turnstile is configured, but no token is received, an error message should be shown.
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error message if turnstile is configured, but no token is received", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
// Since Turnstile is configured, but no token is received, an error message should be shown.
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("auth.signup.please_verify_captcha");
|
||||
});
|
||||
});
|
||||
|
||||
it("Invite token is in the search params", async () => {
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("inviteToken=token123") as any);
|
||||
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
// Check that the invite token is passed to the createUserAction
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "token123",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import { FormProvider, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import Turnstile, { useTurnstile } from "react-turnstile";
|
||||
import { z } from "zod";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
import { createEmailTokenAction } from "../../../auth/actions";
|
||||
@@ -31,8 +30,6 @@ const ZSignupInput = z.object({
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
const turnstileSiteKey = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||
|
||||
type TSignupInput = z.infer<typeof ZSignupInput>;
|
||||
|
||||
interface SignupFormProps {
|
||||
@@ -55,6 +52,7 @@ interface SignupFormProps {
|
||||
isTurnstileConfigured: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
turnstileSiteKey?: string;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -77,6 +75,7 @@ export const SignupForm = ({
|
||||
isTurnstileConfigured,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
turnstileSiteKey,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -171,10 +170,11 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
data-testid="signup-name"
|
||||
value={field.value}
|
||||
name="name"
|
||||
autoFocus
|
||||
onChange={(name) => field.onChange(name)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="bg-white"
|
||||
/>
|
||||
@@ -192,9 +192,10 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
data-testid="signup-email"
|
||||
value={field.value}
|
||||
name="email"
|
||||
onChange={(email) => field.onChange(email)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="work@email.com"
|
||||
className="bg-white"
|
||||
/>
|
||||
@@ -212,10 +213,11 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
data-testid="signup-password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
@@ -248,6 +250,7 @@ export const SignupForm = ({
|
||||
|
||||
{showLogin && (
|
||||
<Button
|
||||
data-testid="signup-submit"
|
||||
type="submit"
|
||||
className="h-10 w-full justify-center"
|
||||
loading={form.formState.isSubmitting}
|
||||
@@ -258,6 +261,7 @@ export const SignupForm = ({
|
||||
|
||||
{!showLogin && (
|
||||
<Button
|
||||
data-testid="signup-show-login"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -50,23 +51,50 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
// Mock environment variables and constants
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
SAML_AUDIENCE: "test-saml-audience",
|
||||
SAML_PATH: "test-saml-path",
|
||||
SAML_DATABASE_URL: "test-saml-database-url",
|
||||
TERMS_URL: "test-terms-url",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
PRIVACY_URL: "test-privacy-url",
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
GOOGLE_OAUTH_ENABLED: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
AZURE_OAUTH_ENABLED: true,
|
||||
OIDC_OAUTH_ENABLED: true,
|
||||
OIDC_DISPLAY_NAME: "OpenID",
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
SAML_TENANT: "test-tenant",
|
||||
SAML_PRODUCT: "test-product",
|
||||
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
|
||||
IS_TURNSTILE_CONFIGURED: true,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
DEFAULT_ORGANIZATION_ID: "test-org-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "admin",
|
||||
SAML_TENANT: "test-saml-tenant",
|
||||
SAML_PRODUCT: "test-saml-product",
|
||||
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
}));
|
||||
|
||||
describe("SignupPage", () => {
|
||||
@@ -88,8 +116,11 @@ describe("SignupPage", () => {
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
|
||||
|
||||
const result = await SignupPage({ searchParams: mockSearchParams });
|
||||
@@ -128,7 +159,10 @@ describe("SignupPage", () => {
|
||||
it("calls notFound when invite token is valid but invite is not found", async () => {
|
||||
// Mock the license check functions to return false
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(false);
|
||||
|
||||
await SignupPage({ searchParams: { inviteToken: "test-token" } });
|
||||
@@ -141,8 +175,11 @@ describe("SignupPage", () => {
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
|
||||
|
||||
const result = await SignupPage({ searchParams: { email: "test@example.com" } });
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SAML_TENANT,
|
||||
SIGNUP_ENABLED,
|
||||
TERMS_URL,
|
||||
TURNSTILE_SITE_KEY,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
@@ -83,6 +84,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal file
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import {
|
||||
TBaseFilters,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import { getSegment } from "../segments";
|
||||
|
||||
// Type for the result of the segment filter to prisma query generation
|
||||
export type SegmentFilterQueryResult = {
|
||||
whereClause: Prisma.ContactWhereInput;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment attribute filter
|
||||
*/
|
||||
const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
// This base query checks if the contact has an attribute with the specified key
|
||||
const baseQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: contactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Handle special operators that don't require a value
|
||||
if (operator === "isSet") {
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (operator === "isNotSet") {
|
||||
return {
|
||||
NOT: baseQuery,
|
||||
};
|
||||
}
|
||||
|
||||
// For all other operators, we need to check the attribute value
|
||||
const valueQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: contactAttributeKey,
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
// Apply the appropriate operator to the attribute value
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "notEquals":
|
||||
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "contains":
|
||||
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "doesNotContain":
|
||||
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
|
||||
break;
|
||||
case "startsWith":
|
||||
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "endsWith":
|
||||
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "greaterThan":
|
||||
valueQuery.attributes.some.value = { gt: String(value) };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
valueQuery.attributes.some.value = { gte: String(value) };
|
||||
break;
|
||||
case "lessThan":
|
||||
valueQuery.attributes.some.value = { lt: String(value) };
|
||||
break;
|
||||
case "lessEqual":
|
||||
valueQuery.attributes.some.value = { lte: String(value) };
|
||||
break;
|
||||
default:
|
||||
valueQuery.attributes.some.value = String(value);
|
||||
}
|
||||
|
||||
return valueQuery;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a person filter
|
||||
*/
|
||||
const buildPersonFilterWhereClause = (filter: TSegmentPersonFilter): Prisma.ContactWhereInput => {
|
||||
const { personIdentifier } = filter.root;
|
||||
|
||||
if (personIdentifier === "userId") {
|
||||
const personFilter: TSegmentAttributeFilter = {
|
||||
...filter,
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: personIdentifier,
|
||||
},
|
||||
};
|
||||
return buildAttributeFilterWhereClause(personFilter);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a device filter
|
||||
*/
|
||||
const buildDeviceFilterWhereClause = (filter: TSegmentDeviceFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { type } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
const baseQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: type,
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
if (operator === "equals") {
|
||||
baseQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
|
||||
} else if (operator === "notEquals") {
|
||||
baseQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment filter
|
||||
*/
|
||||
const buildSegmentFilterWhereClause = async (
|
||||
filter: TSegmentSegmentFilter,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root } = filter;
|
||||
const { segmentId } = root;
|
||||
|
||||
if (segmentPath.has(segmentId)) {
|
||||
logger.error(
|
||||
{ segmentId, path: Array.from(segmentPath) },
|
||||
"Circular reference detected in segment filter"
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const segment = await getSegment(segmentId);
|
||||
|
||||
if (!segment) {
|
||||
logger.error({ segmentId }, "Segment not found");
|
||||
return {};
|
||||
}
|
||||
|
||||
const newPath = new Set(segmentPath);
|
||||
newPath.add(segmentId);
|
||||
|
||||
return processFilters(segment.filters, newPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes a segment filter or group and returns a Prisma where clause
|
||||
*/
|
||||
const processSingleFilter = async (
|
||||
filter: TSegmentFilter,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root } = filter;
|
||||
|
||||
switch (root.type) {
|
||||
case "attribute":
|
||||
return buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
|
||||
case "person":
|
||||
return buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
|
||||
case "device":
|
||||
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter);
|
||||
case "segment":
|
||||
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes filters and returns a combined Prisma where clause
|
||||
*/
|
||||
const processFilters = async (
|
||||
filters: TBaseFilters,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
if (filters.length === 0) return {};
|
||||
|
||||
const query: { AND: Prisma.ContactWhereInput[]; OR: Prisma.ContactWhereInput[] } = {
|
||||
AND: [],
|
||||
OR: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
const { resource, connector } = filters[i];
|
||||
let whereClause: Prisma.ContactWhereInput;
|
||||
|
||||
// Process the resource based on its type
|
||||
if (isResourceFilter(resource)) {
|
||||
// If it's a single filter, process it directly
|
||||
whereClause = await processSingleFilter(resource, segmentPath);
|
||||
} else {
|
||||
// If it's a group of filters, process it recursively
|
||||
whereClause = await processFilters(resource, segmentPath);
|
||||
}
|
||||
|
||||
if (Object.keys(whereClause).length === 0) continue;
|
||||
if (filters.length === 1) query.AND = [whereClause];
|
||||
else {
|
||||
if (i === 0) {
|
||||
if (filters[1].connector === "and") query.AND.push(whereClause);
|
||||
else query.OR.push(whereClause);
|
||||
} else {
|
||||
if (connector === "and") query.AND.push(whereClause);
|
||||
else query.OR.push(whereClause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(query.AND.length > 0 ? { AND: query.AND } : {}),
|
||||
...(query.OR.length > 0 ? { OR: query.OR } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms a segment filter into a Prisma query for contacts
|
||||
*/
|
||||
export const segmentFilterToPrismaQuery = reactCache(
|
||||
async (segmentId: string, filters: TBaseFilters, environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<SegmentFilterQueryResult, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const baseWhereClause = {
|
||||
environmentId,
|
||||
};
|
||||
|
||||
// Initialize an empty stack for tracking the current evaluation path
|
||||
const segmentPath = new Set<string>([segmentId]);
|
||||
const filtersWhereClause = await processFilters(filters, segmentPath);
|
||||
|
||||
const whereClause = {
|
||||
AND: [baseWhereClause, filtersWhereClause],
|
||||
};
|
||||
|
||||
return ok({ whereClause });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, segmentId, environmentId },
|
||||
"Error transforming segment filter to Prisma query"
|
||||
);
|
||||
return err({
|
||||
type: "bad_request",
|
||||
message: "Failed to convert segment filters to Prisma query",
|
||||
details: [{ field: "segment", issue: "Invalid segment filters" }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`segmentFilterToPrismaQuery-${segmentId}-${environmentId}-${JSON.stringify(filters)}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byEnvironmentId(environmentId), segmentCache.tag.byId(segmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -15,7 +16,7 @@ import { Modal } from "@/modules/ui/components/modal";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertTriangleIcon, ChevronDownIcon, Trash2Icon } from "lucide-react";
|
||||
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -389,11 +390,9 @@ export const AddApiKeyModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
|
||||
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
|
||||
<p>{t("environments.project.api_keys.api_key_security_warning")}</p>
|
||||
</div>
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import packageJson from "@/package.json";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getProjects } from "@formbricks/lib/project/service";
|
||||
import { DeleteProject } from "./components/delete-project";
|
||||
import { EditProjectNameForm } from "./components/edit-project-name-form";
|
||||
@@ -51,7 +51,7 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
|
||||
</SettingsCard>
|
||||
<div>
|
||||
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
|
||||
{!IS_FORMBRICKS_CLOUD && (
|
||||
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
|
||||
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
|
||||
)}
|
||||
</div>
|
||||
|
||||
97
apps/web/modules/setup/(fresh-instance)/signup/page.test.tsx
Normal file
97
apps/web/modules/setup/(fresh-instance)/signup/page.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { SignupPage } from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
SAML_AUDIENCE: "test-saml-audience",
|
||||
SAML_PATH: "test-saml-path",
|
||||
SAML_DATABASE_URL: "test-saml-database-url",
|
||||
TERMS_URL: "test-terms-url",
|
||||
SIGNUP_ENABLED: true,
|
||||
PRIVACY_URL: "test-privacy-url",
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
GOOGLE_OAUTH_ENABLED: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
AZURE_OAUTH_ENABLED: true,
|
||||
OIDC_OAUTH_ENABLED: true,
|
||||
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
|
||||
IS_TURNSTILE_CONFIGURED: true,
|
||||
SAML_TENANT: "test-saml-tenant",
|
||||
SAML_PRODUCT: "test-saml-product",
|
||||
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getisSsoEnabled: vi.fn(),
|
||||
getIsSamlSsoEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the SignupForm component to simplify our test assertions
|
||||
vi.mock("@/modules/auth/signup/components/signup-form", () => ({
|
||||
SignupForm: (props) => (
|
||||
<div data-testid="signup-form" data-turnstile-key={props.turnstileSiteKey}>
|
||||
SignupForm
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("SignupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(getTranslate).mockResolvedValue((key) => key);
|
||||
});
|
||||
|
||||
it("renders the signup page correctly", async () => {
|
||||
const page = await SignupPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByTestId("signup-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-form")).toHaveAttribute(
|
||||
"data-turnstile-key",
|
||||
"test-turnstile-site-key"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
TERMS_URL,
|
||||
TURNSTILE_SITE_KEY,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
@@ -59,6 +60,7 @@ export const SignupPage = async () => {
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getDefaultEndingCard } from "@/app/lib/templates";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
|
||||
@@ -8,8 +9,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Environment } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon, CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -106,7 +106,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
className="h-full w-full cursor-pointer"
|
||||
id="howToSendCardTrigger">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
@@ -171,23 +171,25 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
|
||||
{localSurvey.type === option.id && option.alert && (
|
||||
<div className="mt-2 flex items-center space-x-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2">
|
||||
<AlertCircleIcon className="h-5 w-5 text-amber-500" />
|
||||
<div className="text-amber-800">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("environments.surveys.edit.formbricks_sdk_is_not_connected")}
|
||||
</p>
|
||||
<p className="text-xs font-normal">
|
||||
<Link
|
||||
href={`/environments/${environment.id}/project/${option.id}-connection`}
|
||||
className="underline hover:text-amber-900"
|
||||
target="_blank">
|
||||
{t("common.connect_formbricks")}
|
||||
</Link>{" "}
|
||||
{t("environments.surveys.edit.and_launch_surveys_in_your_website_or_app")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="warning" className="mt-2">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.edit.formbricks_sdk_is_not_connected")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("common.connect_formbricks") +
|
||||
" " +
|
||||
t("environments.surveys.edit.and_launch_surveys_in_your_website_or_app")}
|
||||
</AlertDescription>
|
||||
<AlertButton
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/environments/${environment.id}/project/${option.id}-connection`,
|
||||
"_blank"
|
||||
)
|
||||
}>
|
||||
{t("common.connect_formbricks")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
|
||||
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Editor } from "@/modules/ui/components/editor";
|
||||
@@ -423,17 +424,13 @@ export const FollowUpModal = ({
|
||||
</Select>
|
||||
|
||||
{triggerType === "endings" && !localSurvey.endings.length ? (
|
||||
<div className="mt-4 flex items-start text-yellow-600">
|
||||
<TriangleAlertIcon
|
||||
className="mr-2 h-5 min-h-5 w-5 min-w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertTitle>
|
||||
{t(
|
||||
"environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "./index";
|
||||
|
||||
describe("Alert", () => {
|
||||
it("renders with default variant", () => {
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
<AlertDescription>Test Description</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with different variants", () => {
|
||||
const variants = ["default", "error", "warning", "info", "success"] as const;
|
||||
|
||||
variants.forEach((variant) => {
|
||||
const { container } = render(
|
||||
<Alert variant={variant}>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass(
|
||||
variant === "default" ? "text-foreground" : `text-${variant}-foreground`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders with different sizes", () => {
|
||||
const sizes = ["default", "small"] as const;
|
||||
|
||||
sizes.forEach((size) => {
|
||||
const { container } = render(
|
||||
<Alert size={size}>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass(size === "default" ? "py-3" : "py-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders with button and handles click", () => {
|
||||
const handleClick = vi.fn();
|
||||
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
<AlertButton onClick={handleClick}>Click me</AlertButton>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
const button = screen.getByText("Click me");
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
const { container } = render(
|
||||
<Alert className="custom-class">
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -21,23 +21,23 @@ const AlertContext = createContext<AlertContextValue>({
|
||||
const useAlertContext = () => useContext(AlertContext);
|
||||
|
||||
// Define alert styles with variants
|
||||
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 [&>svg]:text-foreground", {
|
||||
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-foreground border-border",
|
||||
error:
|
||||
"text-error-foreground border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted",
|
||||
"text-error-foreground [&>svg]:text-error border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted",
|
||||
warning:
|
||||
"text-warning-foreground border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted",
|
||||
info: "text-info-foreground border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted",
|
||||
"text-warning-foreground [&>svg]:text-warning border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted",
|
||||
info: "text-info-foreground [&>svg]:text-info border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted",
|
||||
success:
|
||||
"text-success-foreground border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted",
|
||||
"text-success-foreground [&>svg]:text-success border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"py-3 px-4 text-sm grid grid-cols-[1fr_auto] grid-rows-[auto_auto] gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
"py-3 px-4 text-sm grid grid-cols-[2fr_auto] grid-rows-[auto_auto] gap-y-0.5 gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
small:
|
||||
"px-3 py-2 text-xs flex items-center justify-between gap-2 [&>svg]:flex-shrink-0 [&_button]:text-xs [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -58,7 +58,7 @@ const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => {
|
||||
const variantIcon = variant ? (variant !== "default" ? alertVariantIcons[variant] : null) : null;
|
||||
const variantIcon = variant && variant !== "default" ? alertVariantIcons[variant] : null;
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={{ variant, size }}>
|
||||
@@ -78,8 +78,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"col-start-1 row-start-1 font-medium leading-none tracking-tight",
|
||||
size === "small" ? "min-w-0 flex-shrink truncate" : "col-start-1 row-start-1",
|
||||
"col-start-1 row-start-1 font-medium tracking-tight",
|
||||
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -99,9 +99,7 @@ const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttrib
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_p]:leading-relaxed",
|
||||
size === "small"
|
||||
? "hidden min-w-0 flex-shrink flex-grow truncate opacity-80 sm:block" // Hidden on very small screens, limited width
|
||||
: "col-start-1 row-start-2",
|
||||
size === "small" ? "flex-shrink flex-grow-0 truncate" : "col-start-1 row-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -124,7 +122,7 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={cn(
|
||||
"self-end",
|
||||
alertSize === "small"
|
||||
? "-my-2 -mr-3 ml-auto flex-shrink-0"
|
||||
? "-my-2 -mr-4 ml-auto flex-shrink-0"
|
||||
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
|
||||
)}>
|
||||
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Default, Error, Info, Small, Success, Warning, withButtonAndIcon } from "./stories";
|
||||
|
||||
describe("Alert Stories", () => {
|
||||
const renderStory = (Story: any) => {
|
||||
return render(Story.render(Story.args));
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders Default story", () => {
|
||||
renderStory(Default);
|
||||
expect(screen.getByText("Alert Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is an important notification.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Small story", () => {
|
||||
renderStory(Small);
|
||||
expect(screen.getByText("Information Alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Learn more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders withButtonAndIcon story", () => {
|
||||
renderStory(withButtonAndIcon);
|
||||
expect(screen.getByText("Alert Title")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Learn more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Error story", () => {
|
||||
renderStory(Error);
|
||||
expect(screen.getByText("Error Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Your session has expired. Please log in again.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Log in")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Warning story", () => {
|
||||
renderStory(Warning);
|
||||
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("You are editing sensitive data. Be cautious")).toBeInTheDocument();
|
||||
expect(screen.getByText("Proceed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Info story", () => {
|
||||
renderStory(Info);
|
||||
expect(screen.getByText("Info Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("There was an update to your application.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Refresh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Success story", () => {
|
||||
renderStory(Success);
|
||||
expect(screen.getByText("Success Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("This worked! Please proceed.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { checkForYoutubeUrl, convertToEmbedUrl, extractYoutubeId } from "@formbricks/lib/utils/videoUpload";
|
||||
@@ -112,10 +112,9 @@ export const VideoSettings = ({
|
||||
</div>
|
||||
|
||||
{showPlatformWarning && (
|
||||
<div className="flex items-center space-x-2 rounded-md border bg-slate-100 p-2 text-xs text-slate-600">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
<p>{t("environments.surveys.edit.invalid_video_url_warning")}</p>
|
||||
</div>
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertTitle>{t("environments.surveys.edit.invalid_video_url_warning")}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isYoutubeLink && (
|
||||
|
||||
@@ -135,7 +135,7 @@ const nextConfig = {
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value:
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -149,7 +149,7 @@ const nextConfig = {
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value:
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
const MOCK_PASSWORD = "Mock_password_for_testing_0nly";
|
||||
|
||||
export const mockUsers = {
|
||||
signup: [
|
||||
{
|
||||
name: "SignUp Flow User 1",
|
||||
email: "signup1@formbricks.com",
|
||||
password: "eN791hZ7wNr9IAscf@",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
onboarding: [
|
||||
{
|
||||
name: "Onboarding User 1",
|
||||
email: "onboarding1@formbricks.com",
|
||||
password: "iHalLonErFGK$X901R0",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Onboarding User 2",
|
||||
email: "onboarding2@formbricks.com",
|
||||
password: "231Xh7D&dM8u75EjIYV",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Onboarding User 3",
|
||||
email: "onboarding3@formbricks.com",
|
||||
password: "231Xh7D&dM8u75EjIYV",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
survey: [
|
||||
{
|
||||
name: "Survey User 1",
|
||||
email: "survey1@formbricks.com",
|
||||
password: "Y1I*EpURUSb32j5XijP",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Survey User 2",
|
||||
email: "survey2@formbricks.com",
|
||||
password: "G73*Gjif22F4JKM1pA",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Survey User 3",
|
||||
email: "survey3@formbricks.com",
|
||||
password: "Gj2DGji27D&M8u53V",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Survey User 4",
|
||||
email: "survey4@formbricks.com",
|
||||
password: "UU3efj8vJa&M8u5M1",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
js: [
|
||||
{
|
||||
name: "JS User 1",
|
||||
email: "js1@formbricks.com",
|
||||
password: "XpP%X9UU3efj8vJa",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
action: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// vitest.config.ts
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { PluginOption, loadEnv } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
@@ -19,14 +19,15 @@ export default defineConfig({
|
||||
"modules/api/v2/**/*.ts",
|
||||
"modules/api/v2/**/*.tsx",
|
||||
"modules/auth/lib/**/*.ts",
|
||||
"modules/signup/lib/**/*.ts",
|
||||
"modules/auth/signup/lib/**/*.ts",
|
||||
"modules/auth/signup/**/*.tsx",
|
||||
"modules/ee/whitelabel/email-customization/components/*.tsx",
|
||||
"modules/ee/role-management/components/*.tsx",
|
||||
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
|
||||
"modules/email/components/email-template.tsx",
|
||||
"modules/email/emails/survey/follow-up.tsx",
|
||||
"modules/ui/components/post-hog-client/*.tsx",
|
||||
"modules/ee/role-management/components/*.tsx",
|
||||
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
|
||||
"modules/ui/components/alert/*.tsx",
|
||||
"app/(app)/environments/**/layout.tsx",
|
||||
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
|
||||
@@ -52,6 +53,7 @@ export default defineConfig({
|
||||
"modules/survey/lib/client-utils.ts",
|
||||
"modules/survey/list/components/survey-card.tsx",
|
||||
"modules/survey/list/components/survey-dropdown-menu.tsx",
|
||||
"modules/ee/contacts/segments/lib/**/*.ts",
|
||||
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
|
||||
"modules/ee/sso/components/**/*.tsx",
|
||||
],
|
||||
|
||||
@@ -98,7 +98,7 @@ x-environment: &environment
|
||||
############################################# OPTIONAL (OAUTH CONFIGURATION) #############################################
|
||||
|
||||
# Set the below from Cloudflare Turnstile if you want to enable turnstile in signups
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY:
|
||||
# TURNSTILE_SITE_KEY:
|
||||
# TURNSTILE_SECRET_KEY:
|
||||
|
||||
# Set the below from GitHub if you want to enable GitHub OAuth
|
||||
|
||||
@@ -21,6 +21,8 @@ tags:
|
||||
description: Operations for managing contact attributes keys.
|
||||
- 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.
|
||||
- name: Organizations API > Teams
|
||||
@@ -629,41 +631,53 @@ paths:
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: &a6
|
||||
- createdAt
|
||||
- updatedAt
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: &a7
|
||||
- asc
|
||||
- desc
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: surveyId
|
||||
schema:
|
||||
@@ -2233,6 +2247,106 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/survey"
|
||||
/surveys/{surveyId}/contact-links/segments/{segmentId}:
|
||||
get:
|
||||
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
|
||||
parameters:
|
||||
- in: path
|
||||
name: surveyId
|
||||
description: The ID of the survey
|
||||
schema:
|
||||
type: string
|
||||
description: The ID of the survey
|
||||
required: true
|
||||
- in: path
|
||||
name: segmentId
|
||||
description: The ID of the segment
|
||||
schema:
|
||||
type: string
|
||||
description: The ID of the segment
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: expirationDays
|
||||
description: Number of days until the generated JWT expires. If not provided,
|
||||
there is no expiration.
|
||||
schema:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
default: null
|
||||
description: Number of days until the generated JWT expires. If not provided,
|
||||
there is no expiration.
|
||||
- in: query
|
||||
name: attributeKeys
|
||||
schema:
|
||||
type: string
|
||||
description: 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.
|
||||
responses:
|
||||
"200":
|
||||
description: Contact links generated successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
contactId:
|
||||
type: string
|
||||
description: The ID of the contact
|
||||
surveyUrl:
|
||||
type: string
|
||||
format: uri
|
||||
description: Personalized survey link
|
||||
expiresAt:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
description: The date and time the link expires, null if no expiration
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: The attributes of the contact
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: number
|
||||
limit:
|
||||
type: number
|
||||
offset:
|
||||
type: number
|
||||
/webhooks:
|
||||
get:
|
||||
operationId: getWebhooks
|
||||
@@ -2243,37 +2357,49 @@ paths:
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: surveyIds
|
||||
schema:
|
||||
@@ -2680,37 +2806,49 @@ paths:
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
responses:
|
||||
"200":
|
||||
description: Teams retrieved successfully.
|
||||
@@ -2972,37 +3110,49 @@ paths:
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: teamId
|
||||
schema:
|
||||
@@ -3258,37 +3408,49 @@ paths:
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: id
|
||||
schema:
|
||||
|
||||
@@ -128,7 +128,7 @@ class FormbricksViewModel : ViewModel() {
|
||||
val jsonObject = JsonObject()
|
||||
environmentDataHolder.getSurveyJson(surveyId).let { jsonObject.add("survey", it) }
|
||||
jsonObject.addProperty("isBrandingEnabled", true)
|
||||
jsonObject.addProperty("apiHost", Formbricks.appUrl)
|
||||
jsonObject.addProperty("appUrl", Formbricks.appUrl)
|
||||
jsonObject.addProperty("languageCode", Formbricks.language)
|
||||
jsonObject.addProperty("environmentId", Formbricks.environmentId)
|
||||
jsonObject.addProperty("contactId", UserManager.contactId)
|
||||
|
||||
@@ -14,7 +14,13 @@ import Network
|
||||
static internal var service = FormbricksService()
|
||||
|
||||
// make this class not instantiatable outside of the SDK
|
||||
internal override init() {}
|
||||
internal override init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the Formbricks class.
|
||||
All methods are static and the class serves as a namespace for the SDK,
|
||||
so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
Initializes the Formbricks SDK with the given config ``FormbricksConfig``.
|
||||
|
||||
@@ -22,7 +22,7 @@ struct AnyCodable: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: _AnyEncodable, _AnyDecodable {}
|
||||
extension AnyCodable: AnyEncodableProtocol, AnyDecodableProtocol {}
|
||||
|
||||
extension AnyCodable: Equatable {
|
||||
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
|
||||
@@ -88,12 +88,10 @@ extension AnyCodable: CustomStringConvertible {
|
||||
|
||||
extension AnyCodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch value {
|
||||
case let value as CustomDebugStringConvertible:
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyCodable(\(value.debugDescription))"
|
||||
default:
|
||||
return "AnyCodable(\(description))"
|
||||
}
|
||||
return "AnyCodable(\(description))"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@ struct AnyDecodable: Decodable {
|
||||
}
|
||||
|
||||
@usableFromInline
|
||||
protocol _AnyDecodable {
|
||||
protocol AnyDecodableProtocol {
|
||||
var value: Any { get }
|
||||
init<T>(_ value: T?)
|
||||
}
|
||||
|
||||
extension AnyDecodable: _AnyDecodable {}
|
||||
extension AnyDecodable: AnyDecodableProtocol {}
|
||||
|
||||
extension _AnyDecodable {
|
||||
extension AnyDecodableProtocol {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
@@ -139,10 +139,9 @@ extension AnyDecodable: CustomStringConvertible {
|
||||
|
||||
extension AnyDecodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch value {
|
||||
case let value as CustomDebugStringConvertible:
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyDecodable(\(value.debugDescription))"
|
||||
default:
|
||||
} else {
|
||||
return "AnyDecodable(\(description))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +40,16 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
|
||||
@usableFromInline
|
||||
protocol _AnyEncodable {
|
||||
protocol AnyEncodableProtocol {
|
||||
var value: Any { get }
|
||||
init<T>(_ value: T?)
|
||||
}
|
||||
|
||||
extension AnyEncodable: _AnyEncodable {}
|
||||
extension AnyEncodable: AnyEncodableProtocol {}
|
||||
|
||||
// MARK: - Encodable
|
||||
|
||||
extension _AnyEncodable {
|
||||
extension AnyEncodableProtocol {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
@@ -199,10 +199,9 @@ extension AnyEncodable: CustomStringConvertible {
|
||||
|
||||
extension AnyEncodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch value {
|
||||
case let value as CustomDebugStringConvertible:
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyEncodable(\(value.debugDescription))"
|
||||
default:
|
||||
} else {
|
||||
return "AnyEncodable(\(description))"
|
||||
}
|
||||
}
|
||||
@@ -217,7 +216,7 @@ extension AnyEncodable: ExpressibleByStringInterpolation {}
|
||||
extension AnyEncodable: ExpressibleByArrayLiteral {}
|
||||
extension AnyEncodable: ExpressibleByDictionaryLiteral {}
|
||||
|
||||
extension _AnyEncodable {
|
||||
extension AnyEncodableProtocol {
|
||||
public init(nilLiteral _: ()) {
|
||||
self.init(nil as Any?)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import SwiftUI
|
||||
/// Presents a survey webview to the window's root
|
||||
final class PresentSurveyManager {
|
||||
static let shared = PresentSurveyManager()
|
||||
private init() { }
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the PresentSurveyManager class.
|
||||
The class serves as a namespace for the present method, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
/// The view controller that will present the survey window.
|
||||
private weak var viewController: UIViewController?
|
||||
@@ -29,6 +34,4 @@ final class PresentSurveyManager {
|
||||
func dismissView() {
|
||||
viewController?.dismiss(animated: true)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import SwiftUI
|
||||
/// Filtering surveys based on the user's segments, responses, and displays.
|
||||
final class SurveyManager {
|
||||
static let shared = SurveyManager()
|
||||
private init() { }
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the SurveyManager class.
|
||||
The class serves as a namespace for the shared instance, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
private static let environmentResponseObjectKey = "environmentResponseObjectKey"
|
||||
internal var service = FormbricksService()
|
||||
@@ -124,7 +129,7 @@ private extension SurveyManager {
|
||||
if let environmentResponse = environmentResponse {
|
||||
PresentSurveyManager.shared.present(environmentResponse: environmentResponse, id: id)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// Starts a timer to refresh the environment state after the given timeout (`expiresAt`).
|
||||
@@ -200,7 +205,9 @@ private extension SurveyManager {
|
||||
|
||||
case .displaySome:
|
||||
if let limit = survey.displayLimit {
|
||||
if responses.contains(where: { $0 == survey.id }) { return false }
|
||||
if responses.contains(where: { $0 == survey.id }) {
|
||||
return false
|
||||
}
|
||||
return displays.filter { $0.surveyId == survey.id }.count < limit
|
||||
} else {
|
||||
return true
|
||||
@@ -236,5 +243,5 @@ private extension SurveyManager {
|
||||
return segments.contains(segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import Foundation
|
||||
/// Store and manage user state and sync with the server when needed.
|
||||
final class UserManager {
|
||||
static let shared = UserManager()
|
||||
private init() { }
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the UserManager class.
|
||||
The class serves as a namespace for the user state, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
private static let userIdKey = "userIdKey"
|
||||
private static let contactIdKey = "contactIdKey"
|
||||
|
||||
@@ -54,8 +54,7 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
responseLogMessage.append(urlString)
|
||||
}
|
||||
|
||||
switch httpStatus.responseType {
|
||||
case .success:
|
||||
if httpStatus.responseType == .success {
|
||||
guard let data = data else {
|
||||
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
|
||||
return
|
||||
@@ -73,12 +72,12 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
Formbricks.logger.info(responseLogMessage)
|
||||
|
||||
// We want to save the entire response dictionary for the environment response
|
||||
if var environmentResponse = body as? EnvironmentResponse {
|
||||
if let jsonString = String(data: data, encoding: .utf8) {
|
||||
environmentResponse.responseString = jsonString
|
||||
body = environmentResponse as! Request.Response
|
||||
}
|
||||
if var environmentResponse = body as? EnvironmentResponse,
|
||||
let jsonString = String(data: data, encoding: .utf8) {
|
||||
environmentResponse.responseString = jsonString
|
||||
body = environmentResponse as! Request.Response
|
||||
}
|
||||
|
||||
|
||||
self.completion?(.success(body))
|
||||
}
|
||||
@@ -111,8 +110,7 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
Formbricks.logger.error(responseLogMessage)
|
||||
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
|
||||
}
|
||||
|
||||
default:
|
||||
} else {
|
||||
if let error = error {
|
||||
responseLogMessage.append("\nError: \(error.localizedDescription)")
|
||||
Formbricks.logger.error(responseLogMessage)
|
||||
|
||||
@@ -20,7 +20,7 @@ enum HTTPStatusCode: Int, Error {
|
||||
// MARK: - Informational - 1xx -
|
||||
|
||||
/// - continue: The server has received the request headers and the client should proceed to send the request body.
|
||||
case `continue` = 100
|
||||
case httpContinue = 100
|
||||
|
||||
/// - switchingProtocols: The requester has asked the server to switch protocols and the server has agreed to do so.
|
||||
case switchingProtocols = 101
|
||||
|
||||
@@ -92,7 +92,7 @@ private class WebViewData {
|
||||
data["survey"] = environmentResponse.getSurveyJson(forSurveyId: surveyId)
|
||||
data["isBrandingEnabled"] = true
|
||||
data["languageCode"] = Formbricks.language
|
||||
data["apiHost"] = Formbricks.appUrl
|
||||
data["appUrl"] = Formbricks.appUrl
|
||||
data["environmentId"] = Formbricks.environmentId
|
||||
data["contactId"] = UserManager.shared.contactId
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ struct SurveyWebView: UIViewRepresentable {
|
||||
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
|
||||
WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
|
||||
records.forEach { record in
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {})
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {
|
||||
/*
|
||||
This completion handler is intentionally empty since we only need to
|
||||
ensure the data is removed. No additional actions are required after
|
||||
the website data has been cleared.
|
||||
*/
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +62,13 @@ extension SurveyWebView {
|
||||
// webView function handles Javascipt alert
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in })
|
||||
alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in
|
||||
/*
|
||||
This closure is intentionally empty since we only need a simple OK button
|
||||
to dismiss the alert. The alert dismissal is handled automatically by the
|
||||
system when the button is tapped.
|
||||
*/
|
||||
})
|
||||
UIApplication.safeKeyWindow?.rootViewController?.presentedViewController?.present(alertController, animated: true)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
@@ -261,7 +261,11 @@ export const BILLING_LIMITS = {
|
||||
} as const;
|
||||
|
||||
export const AI_AZURE_LLM_RESSOURCE_NAME = env.AI_AZURE_LLM_RESSOURCE_NAME;
|
||||
|
||||
export const AI_AZURE_LLM_API_KEY = env.AI_AZURE_LLM_API_KEY;
|
||||
export const AI_AZURE_LLM_DEPLOYMENT_ID = env.AI_AZURE_LLM_DEPLOYMENT_ID;
|
||||
export const AI_AZURE_EMBEDDINGS_RESSOURCE_NAME = env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME;
|
||||
export const AI_AZURE_EMBEDDINGS_API_KEY = env.AI_AZURE_EMBEDDINGS_API_KEY;
|
||||
export const AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID = env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID;
|
||||
export const IS_AI_CONFIGURED = Boolean(
|
||||
env.AI_AZURE_EMBEDDINGS_API_KEY &&
|
||||
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID &&
|
||||
@@ -270,11 +274,6 @@ export const IS_AI_CONFIGURED = Boolean(
|
||||
env.AI_AZURE_LLM_DEPLOYMENT_ID &&
|
||||
env.AI_AZURE_LLM_RESSOURCE_NAME
|
||||
);
|
||||
export const AI_AZURE_LLM_API_KEY = env.AI_AZURE_LLM_API_KEY;
|
||||
export const AI_AZURE_LLM_DEPLOYMENT_ID = env.AI_AZURE_LLM_DEPLOYMENT_ID;
|
||||
export const AI_AZURE_EMBEDDINGS_RESSOURCE_NAME = env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME;
|
||||
export const AI_AZURE_EMBEDDINGS_API_KEY = env.AI_AZURE_EMBEDDINGS_API_KEY;
|
||||
export const AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID = env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID;
|
||||
|
||||
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
|
||||
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
|
||||
@@ -285,8 +284,8 @@ export const POSTHOG_API_HOST = env.POSTHOG_API_HOST;
|
||||
export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST);
|
||||
|
||||
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||
|
||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||
|
||||
export const IS_PRODUCTION = env.NODE_ENV === "production";
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export const env = createEnv({
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
TURNSTILE_SECRET_KEY: z.string().optional(),
|
||||
TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
UPLOADS_DIR: z.string().min(1).optional(),
|
||||
VERCEL_URL: z.string().optional(),
|
||||
WEBAPP_URL: z.string().url().optional(),
|
||||
@@ -128,7 +129,6 @@ export const env = createEnv({
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
},
|
||||
/*
|
||||
* Due to how Next.js bundles environment variables on Edge and Client,
|
||||
@@ -188,7 +188,6 @@ export const env = createEnv({
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
|
||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -226,6 +225,7 @@ export const env = createEnv({
|
||||
SURVEY_URL: process.env.SURVEY_URL,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
TERMS_URL: process.env.TERMS_URL,
|
||||
UPLOADS_DIR: process.env.UPLOADS_DIR,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
|
||||
189
pnpm-lock.yaml
generated
189
pnpm-lock.yaml
generated
@@ -47,7 +47,10 @@ importers:
|
||||
version: link:../../packages/js
|
||||
'@tailwindcss/forms':
|
||||
specifier: 0.5.9
|
||||
version: 0.5.9(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))
|
||||
version: 0.5.9(tailwindcss@4.1.3)
|
||||
'@tailwindcss/postcss':
|
||||
specifier: 4.1.3
|
||||
version: 4.1.3
|
||||
lucide-react:
|
||||
specifier: 0.486.0
|
||||
version: 0.486.0(react@19.0.0)
|
||||
@@ -64,8 +67,8 @@ importers:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
tailwindcss:
|
||||
specifier: 3.4.16
|
||||
version: 3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2))
|
||||
specifier: 4.1.3
|
||||
version: 4.1.3
|
||||
devDependencies:
|
||||
'@formbricks/config-typescript':
|
||||
specifier: workspace:*
|
||||
@@ -347,7 +350,7 @@ importers:
|
||||
version: 0.0.35(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@sentry/nextjs':
|
||||
specifier: 8.52.0
|
||||
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))
|
||||
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1)
|
||||
'@tailwindcss/forms':
|
||||
specifier: 0.5.9
|
||||
version: 0.5.9(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))
|
||||
@@ -413,7 +416,7 @@ importers:
|
||||
version: 0.1.13
|
||||
file-loader:
|
||||
specifier: 6.2.0
|
||||
version: 6.2.0(webpack@5.97.1(esbuild@0.25.2))
|
||||
version: 6.2.0(webpack@5.97.1)
|
||||
framer-motion:
|
||||
specifier: 11.15.0
|
||||
version: 11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@@ -548,7 +551,7 @@ importers:
|
||||
version: 11.1.0
|
||||
webpack:
|
||||
specifier: 5.97.1
|
||||
version: 5.97.1(esbuild@0.25.2)
|
||||
version: 5.97.1
|
||||
xlsx:
|
||||
specifier: 0.18.5
|
||||
version: 0.18.5
|
||||
@@ -5230,6 +5233,82 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20'
|
||||
|
||||
'@tailwindcss/node@4.1.3':
|
||||
resolution: {integrity: sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.1.3':
|
||||
resolution: {integrity: sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.1.3':
|
||||
resolution: {integrity: sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.1.3':
|
||||
resolution: {integrity: sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.1.3':
|
||||
resolution: {integrity: sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3':
|
||||
resolution: {integrity: sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.1.3':
|
||||
resolution: {integrity: sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.3':
|
||||
resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.3':
|
||||
resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.3':
|
||||
resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.1.3':
|
||||
resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.1.3':
|
||||
resolution: {integrity: sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.1.3':
|
||||
resolution: {integrity: sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@tailwindcss/postcss@4.1.3':
|
||||
resolution: {integrity: sha512-6s5nJODm98F++QT49qn8xJKHQRamhYHfMi3X7/ltxiSQ9dyRsaFSfFkfaMsanWzf+TMYQtbk8mt5f6cCVXJwfg==}
|
||||
|
||||
'@tailwindcss/typography@0.5.15':
|
||||
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
|
||||
peerDependencies:
|
||||
@@ -12435,6 +12514,9 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tailwindcss@4.1.3:
|
||||
resolution: {integrity: sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==}
|
||||
|
||||
tapable@2.2.1:
|
||||
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -19136,7 +19218,7 @@ snapshots:
|
||||
|
||||
'@sentry/core@8.52.0': {}
|
||||
|
||||
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))':
|
||||
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.30.0
|
||||
@@ -19147,7 +19229,7 @@ snapshots:
|
||||
'@sentry/opentelemetry': 8.52.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.30.0)
|
||||
'@sentry/react': 8.52.0(react@19.0.0)
|
||||
'@sentry/vercel-edge': 8.52.0
|
||||
'@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(webpack@5.97.1(esbuild@0.25.2))
|
||||
'@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(webpack@5.97.1)
|
||||
chalk: 3.0.0
|
||||
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
resolve: 1.22.8
|
||||
@@ -19223,12 +19305,12 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@sentry/core': 8.52.0
|
||||
|
||||
'@sentry/webpack-plugin@2.22.7(encoding@0.1.13)(webpack@5.97.1(esbuild@0.25.2))':
|
||||
'@sentry/webpack-plugin@2.22.7(encoding@0.1.13)(webpack@5.97.1)':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 2.22.7(encoding@0.1.13)
|
||||
unplugin: 1.0.1
|
||||
uuid: 9.0.1
|
||||
webpack: 5.97.1(esbuild@0.25.2)
|
||||
webpack: 5.97.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -19851,6 +19933,73 @@ snapshots:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2))
|
||||
|
||||
'@tailwindcss/forms@0.5.9(tailwindcss@4.1.3)':
|
||||
dependencies:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/node@4.1.3':
|
||||
dependencies:
|
||||
enhanced-resolve: 5.18.1
|
||||
jiti: 2.4.2
|
||||
lightningcss: 1.29.2
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.1.3':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.1.3
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.1.3
|
||||
'@tailwindcss/oxide-darwin-x64': 4.1.3
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.1.3
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.3
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.3
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.1.3
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.1.3
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.1.3
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.3
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.3
|
||||
|
||||
'@tailwindcss/postcss@4.1.3':
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.1.3
|
||||
'@tailwindcss/oxide': 4.1.3
|
||||
postcss: 8.5.3
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))':
|
||||
dependencies:
|
||||
lodash.castarray: 4.4.0
|
||||
@@ -23289,11 +23438,11 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
file-loader@6.2.0(webpack@5.97.1(esbuild@0.25.2)):
|
||||
file-loader@6.2.0(webpack@5.97.1):
|
||||
dependencies:
|
||||
loader-utils: 2.0.4
|
||||
schema-utils: 3.3.0
|
||||
webpack: 5.97.1(esbuild@0.25.2)
|
||||
webpack: 5.97.1
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
@@ -24421,8 +24570,7 @@ snapshots:
|
||||
|
||||
jiti@2.4.1: {}
|
||||
|
||||
jiti@2.4.2:
|
||||
optional: true
|
||||
jiti@2.4.2: {}
|
||||
|
||||
jju@1.4.0: {}
|
||||
|
||||
@@ -24791,7 +24939,6 @@ snapshots:
|
||||
lightningcss-linux-x64-musl: 1.29.2
|
||||
lightningcss-win32-arm64-msvc: 1.29.2
|
||||
lightningcss-win32-x64-msvc: 1.29.2
|
||||
optional: true
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
@@ -28534,6 +28681,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
tailwindcss@4.1.3: {}
|
||||
|
||||
tapable@2.2.1: {}
|
||||
|
||||
tar-fs@2.1.2:
|
||||
@@ -28616,16 +28765,14 @@ snapshots:
|
||||
ansi-escapes: 4.3.2
|
||||
supports-hyperlinks: 2.3.0
|
||||
|
||||
terser-webpack-plugin@5.3.14(esbuild@0.25.2)(webpack@5.97.1(esbuild@0.25.2)):
|
||||
terser-webpack-plugin@5.3.14(webpack@5.97.1):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.0
|
||||
serialize-javascript: 6.0.2
|
||||
terser: 5.39.0
|
||||
webpack: 5.97.1(esbuild@0.25.2)
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.2
|
||||
webpack: 5.97.1
|
||||
|
||||
terser@5.37.0:
|
||||
dependencies:
|
||||
@@ -29537,7 +29684,7 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webpack@5.97.1(esbuild@0.25.2):
|
||||
webpack@5.97.1:
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.7
|
||||
@@ -29559,7 +29706,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 3.3.0
|
||||
tapable: 2.2.1
|
||||
terser-webpack-plugin: 5.3.14(esbuild@0.25.2)(webpack@5.97.1(esbuild@0.25.2))
|
||||
terser-webpack-plugin: 5.3.14(webpack@5.97.1)
|
||||
watchpack: 2.4.2
|
||||
webpack-sources: 3.2.3
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"dependsOn": ["@formbricks/api#build"],
|
||||
"persistent": true
|
||||
},
|
||||
"@formbricks/database#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"@formbricks/database#lint": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
@@ -162,7 +166,6 @@
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_API_HOST",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID",
|
||||
"NEXT_PUBLIC_TURNSTILE_SITE_KEY",
|
||||
"OPENTELEMETRY_LISTENER_URL",
|
||||
"NEXT_RUNTIME",
|
||||
"NEXTAUTH_SECRET",
|
||||
@@ -208,6 +211,7 @@
|
||||
"SURVEY_URL",
|
||||
"TELEMETRY_DISABLED",
|
||||
"TURNSTILE_SECRET_KEY",
|
||||
"TURNSTILE_SITE_KEY",
|
||||
"TERMS_URL",
|
||||
"UPLOADS_DIR",
|
||||
"VERCEL",
|
||||
|
||||
Reference in New Issue
Block a user