Compare commits

...

12 Commits

Author SHA1 Message Date
Piyush Gupta
7540c64fdf fix: sonarqube target 2025-04-08 22:22:12 +05:30
Matti Nannt
3b815e22e3 chore: add docker build check github action (#4875)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-08 13:26:48 +00:00
Matti Nannt
4d4a5c0e64 fix: solve sonarqube security hotspots (#5292) 2025-04-08 14:58:24 +02:00
Anshuman Pandey
0e89293974 fix: appUrl fix in iOS and android packages (#5295) 2025-04-08 14:51:30 +02:00
Jakob Schott
c306911b3a fix: replace hard-coded alerts with alert component (#5156)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-08 10:26:28 +00:00
Piyush Gupta
4f276f0095 feat: personalized survey links for segment of users endpoint (#5032)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-08 05:54:27 +00:00
Dhruwang Jariwala
81fc97c7e9 fix: Add Cache-Control to allowed CORS headers (#5252) 2025-04-07 14:47:02 +00:00
Matti Nannt
785c5a59c6 chore: make mock passwords more obvious to test suites (#5240) 2025-04-07 12:40:40 +00:00
Piyush Gupta
25ecfaa883 fix: formbricks version on localhost (#5250) 2025-04-07 10:42:13 +00:00
Anshuman Pandey
38e2c019fa fix: ios package sonarqube fixes (#5249) 2025-04-07 08:48:56 +00:00
victorvhs017
15878a4ac5 chore: Refactored the Turnstile next public env variable and added test files (#4997)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-07 06:07:39 +00:00
Matti Nannt
9802536ded chore: upgrade demo app to tailwind v4 (#5237)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-07 05:40:10 +00:00
68 changed files with 4081 additions and 383 deletions

View File

@@ -117,7 +117,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS=
# Configure Turnstile in signup flow
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Configure Github Login

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

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
- main
pull_request:
pull_request_target:
types: [opened, synchronize, reopened]
merge_group:
permissions:

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element {
return (
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 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>
))}

View File

@@ -1,3 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

View File

@@ -13,12 +13,13 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwindcss": "3.4.16"
"tailwindcss": "4.1.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -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&apos;re connected with env:</p>

View File

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

View File

@@ -1,13 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};

View File

@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions
@@ -40,8 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -87,31 +85,60 @@ RUN apk add --no-cache curl \
WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json .
RUN chmod 644 ./package.json
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
RUN chmod -R 755 ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
RUN chmod -R 755 ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chmod 644 ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
RUN chmod 644 ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
RUN chmod -R 755 ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
RUN chmod -R 755 ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
RUN chmod -R 755 ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
RUN chmod -R 755 ./node_modules/.prisma
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
# Copy required dependencies
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty

View File

@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
organizationProjectsLimit={organizationProjectsLimit}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}

View File

@@ -63,6 +63,7 @@ interface NavigationProps {
projects: TProject[];
isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
@@ -79,6 +80,7 @@ export const MainNavigation = ({
isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -296,7 +298,7 @@ export const MainNavigation = ({
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"

View File

@@ -1,9 +1,7 @@
// PosthogIdentify.test.tsx
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";

View File

@@ -33,12 +33,16 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({

View File

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

View File

@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SentryProvider } from "./SentryProvider";
vi.mock("@sentry/nextjs", async () => {

View File

@@ -0,0 +1,33 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKeys = reactCache((environmentId: string) =>
cache(
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
try {
const contactAttributeKeys = await prisma.contactAttributeKey.findMany({
where: { environmentId },
select: {
key: true,
},
});
const keys = contactAttributeKeys.map((key) => key.key);
return ok(keys);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contact attribute keys", issue: error.message }],
});
}
},
[`getContactAttributeKeys-contact-links-${environmentId}`],
{
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -0,0 +1,147 @@
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
import { getSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys";
import { TContactWithAttributes } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactsInSegment = reactCache(
(surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) =>
cache(
async (): Promise<Result<ApiResponseWithMeta<TContactWithAttributes[]>, ApiErrorResponseV2>> => {
try {
const surveyResult = await getSurvey(surveyId);
if (!surveyResult.ok) {
return err(surveyResult.error);
}
const survey = surveyResult.data;
if (survey.type !== "link" || survey.status !== "inProgress") {
logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress");
const error: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
return err(error);
}
const segmentResult = await getSegment(segmentId);
if (!segmentResult.ok) {
return err(segmentResult.error);
}
const segment = segmentResult.data;
if (survey.environmentId !== segment.environmentId) {
logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment");
const error: ApiErrorResponseV2 = {
type: "bad_request",
details: [{ field: "segmentId", issue: "Environment mismatch" }],
};
return err(error);
}
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
segment.id,
segment.filters,
segment.environmentId
);
if (!segmentFilterToPrismaQueryResult.ok) {
return err(segmentFilterToPrismaQueryResult.error);
}
const { whereClause } = segmentFilterToPrismaQueryResult.data;
const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId);
if (!contactAttributeKeysResult.ok) {
return err(contactAttributeKeysResult.error);
}
const allAttributeKeys = contactAttributeKeysResult.data;
const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim());
const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field));
const allowedAttributes = attributesToInclude.slice(0, 20);
const [totalContacts, contacts] = await prisma.$transaction([
prisma.contact.count({
where: whereClause,
}),
prisma.contact.findMany({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: allowedAttributes,
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
take: limit,
skip: skip,
orderBy: {
createdAt: "desc",
},
}),
]);
const contactsWithAttributes = contacts.map((contact) => {
const attributes = contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
return {
contactId: contact.id,
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
};
});
return ok({
data: contactsWithAttributes,
meta: {
total: totalContacts,
limit: limit,
offset: skip,
},
});
} catch (error) {
logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment");
const apiError: ApiErrorResponseV2 = {
type: "internal_server_error",
};
return err(apiError);
}
},
[`getContactsInSegment-${surveyId}-${segmentId}-${attributeKeys}-${limit}-${skip}`],
{
tags: [segmentCache.tag.byId(segmentId), surveyCache.tag.byId(surveyId)],
}
)()
);

View File

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

View File

@@ -0,0 +1,36 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Segment } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSegment = reactCache(async (segmentId: string) =>
cache(
async (): Promise<Result<Pick<Segment, "id" | "environmentId" | "filters">, ApiErrorResponseV2>> => {
try {
const segment = await prisma.segment.findUnique({
where: { id: segmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
if (!segment) {
return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] });
}
return ok(segment);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] });
}
},
[`contact-link-getSegment-${segmentId}`],
{
tags: [segmentCache.tag.byId(segmentId)],
}
)()
);

View File

@@ -0,0 +1,39 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Survey } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSurvey = reactCache(async (surveyId: string) =>
cache(
async (): Promise<
Result<Pick<Survey, "id" | "environmentId" | "type" | "status">, ApiErrorResponseV2>
> => {
try {
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
select: {
id: true,
environmentId: true,
type: true,
status: true,
},
});
if (!survey) {
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
}
return ok(survey);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
}
},
[`contact-link-getSurvey-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,52 @@
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttributeKey: {
findMany: vi.fn(),
},
},
}));
describe("getContactAttributeKeys", () => {
const mockEnvironmentId = "mock-env-123";
const mockContactAttributeKeys = [{ key: "email" }, { key: "name" }, { key: "userId" }];
beforeEach(() => {
vi.clearAllMocks();
});
test("successfully retrieves contact attribute keys", async () => {
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockContactAttributeKeys);
const result = await getContactAttributeKeys(mockEnvironmentId);
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
where: { environmentId: mockEnvironmentId },
select: { key: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(["email", "name", "userId"]);
}
});
test("handles database error gracefully", async () => {
const mockError = new Error("Database error");
vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(mockError);
const result = await getContactAttributeKeys(mockEnvironmentId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "contact attribute keys", issue: mockError.message }],
});
}
});
});

View File

@@ -0,0 +1,515 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { SurveyStatus, SurveyType } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TBaseFilters } from "@formbricks/types/segment";
import { getContactsInSegment } from "../contact";
import { getSegment } from "../segment";
import { getSurvey } from "../surveys";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findMany: vi.fn(),
count: vi.fn(),
},
contactAttributeKey: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("../segment", () => ({
getSegment: vi.fn(),
}));
vi.mock("../surveys", () => ({
getSurvey: vi.fn(),
}));
describe("getContactsInSegment", () => {
const mockSurveyId = "survey-123";
const mockSegmentId = "segment-456";
const mockLimit = 10;
const mockSkip = 0;
const mockEnvironmentId = "env-789";
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
type: "link" as SurveyType,
status: "inProgress" as SurveyStatus,
};
// Define filters as a TBaseFilters array with correct structure
const mockFilters: TBaseFilters = [
{
id: "filter-1",
connector: null,
resource: {
id: "resource-1",
root: {
type: "attribute",
contactAttributeKey: "email",
},
value: "test@example.com",
qualifier: {
operator: "equals",
},
},
},
];
const mockSegment = {
id: mockSegmentId,
environmentId: mockEnvironmentId,
filters: mockFilters,
};
const mockContacts = [
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: mockSurvey,
});
vi.mocked(getSegment).mockResolvedValue({
ok: true,
data: mockSegment,
});
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([{ key: "email" }, { key: "name" }]);
vi.mocked(prisma.contact.count).mockResolvedValue(2);
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return contacts when all operations succeed", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
const attributeKeys = "email,name";
const result = await getContactsInSegment(
mockSurveyId,
mockSegmentId,
mockLimit,
mockSkip,
attributeKeys
);
const whereClause = {
AND: [
{
environmentId: "env-789",
},
{
AND: [
{
attributes: {
some: {
attributeKey: {
key: "email",
},
value: { equals: "test@example.com", mode: "insensitive" },
},
},
},
],
},
],
};
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
where: {
environmentId: mockEnvironmentId,
},
select: {
key: true,
},
});
expect(prisma.contact.count).toHaveBeenCalledWith({
where: whereClause,
});
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: whereClause,
select: {
id: true,
attributes: {
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
where: {
attributeKey: {
key: {
in: ["email", "name"],
},
},
},
},
},
take: mockLimit,
skip: mockSkip,
orderBy: {
createdAt: "desc",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should filter contact attributes when fields parameter is provided", async () => {
const filteredMockContacts = [
{
id: "contact-1",
attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }],
},
{
id: "contact-2",
attributes: [{ attributeKey: { key: "email" }, value: "another@example.com" }],
},
];
vi.mocked(prisma.$transaction).mockResolvedValue([filteredMockContacts.length, filteredMockContacts]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email");
const whereClause = {
AND: [
{
environmentId: "env-789",
},
{
AND: [
{
attributes: {
some: {
attributeKey: {
key: "email",
},
value: { equals: "test@example.com", mode: "insensitive" },
},
},
},
],
},
],
};
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).toHaveBeenCalledWith({
where: whereClause,
});
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: ["email"],
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
take: mockLimit,
skip: mockSkip,
orderBy: {
createdAt: "desc",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should handle multiple fields when fields parameter has comma-separated values", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email,name");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should return no attributes but still return contacts when fields parameter is empty", async () => {
const mockContactsWithoutAttributes = mockContacts.map((contact) => ({
...contact,
attributes: [],
}));
vi.mocked(prisma.$transaction).mockResolvedValue([
mockContactsWithoutAttributes.length,
mockContactsWithoutAttributes,
]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: mockContacts.map((contact) => ({
contactId: contact.id,
})),
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should return error when survey is not a link survey", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: {
...mockSurvey,
type: "web" as SurveyType,
},
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when survey is not active", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: {
...mockSurvey,
status: "completed" as SurveyStatus,
},
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when survey is not found", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: false,
error: surveyError,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when segment is not found", async () => {
const segmentError: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "segment", issue: "not found" }],
};
vi.mocked(getSegment).mockResolvedValue({
ok: false,
error: segmentError,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).not.toHaveBeenCalled();
expect(prisma.contact.findMany).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(segmentError);
}
});
test("should return error when survey and segment are in different environments", async () => {
const mockSegmentWithDifferentEnv = {
...mockSegment,
environmentId: "different-env",
};
vi.mocked(getSegment).mockResolvedValue({
ok: true,
data: mockSegmentWithDifferentEnv,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).not.toHaveBeenCalled();
expect(prisma.contact.findMany).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
details: [{ field: "segmentId", issue: "Environment mismatch" }],
});
}
});
test("should return error when database operation fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.contact.count).mockRejectedValue(dbError);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
});
}
});
});

View File

@@ -0,0 +1,129 @@
import { Segment } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { getSegment } from "../segment";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
segment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/lib/cache/segment", () => ({
segmentCache: {
tag: {
byId: vi.fn((id) => `segment-${id}`),
},
},
}));
describe("getSegment", () => {
const mockSegmentId = "segment-123";
const mockSegment: Pick<Segment, "id" | "environmentId" | "filters"> = {
id: mockSegmentId,
environmentId: "env-123",
filters: [
{
id: "filter-123",
connector: null,
resource: {
id: "attr_1",
root: {
type: "attribute",
contactAttributeKey: "email",
},
value: "test@example.com",
qualifier: { operator: "equals" },
},
},
],
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return segment data when segment is found", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
where: { id: mockSegmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSegment);
}
expect(segmentCache.tag.byId).toHaveBeenCalledWith(mockSegmentId);
});
test("should return not_found error when segment doesn't exist", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(null);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
where: { id: mockSegmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "segment", issue: "not found" }],
});
}
});
test("should return internal_server_error when database throws an error", async () => {
const mockError = new Error("Database connection failed");
vi.mocked(prisma.segment.findUnique).mockRejectedValueOnce(mockError);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "segment", issue: "Database connection failed" }],
});
}
});
test("should use correct cache key", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
await getSegment(mockSegmentId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSegment-${mockSegmentId}`], {
tags: [`segment-${mockSegmentId}`],
});
});
});

View File

@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSurvey } from "../surveys";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/lib/survey/cache", () => ({
surveyCache: {
tag: {
byId: vi.fn((id) => `survey-${id}`),
},
},
}));
describe("getSurvey", () => {
const mockSurveyId = "survey-123";
const mockEnvironmentId = "env-456";
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return survey data when survey is found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: mockSurveyId },
select: {
id: true,
environmentId: true,
status: true,
type: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSurvey);
}
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
tags: [`survey-${mockSurveyId}`],
});
});
test("should return not_found error when survey doesn't exist", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: mockSurveyId },
select: {
id: true,
environmentId: true,
status: true,
type: true,
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
});
}
});
test("should return internal_server_error when database throws an error", async () => {
const mockError = new Error("Database connection failed");
vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(mockError);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "survey", issue: "Database connection failed" }],
});
}
});
test("should use correct cache key and tags", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
await getSurvey(mockSurveyId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
tags: [`survey-${mockSurveyId}`],
});
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
});
});

View File

@@ -0,0 +1,116 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
import {
ZContactLinksBySegmentParams,
ZContactLinksBySegmentQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string; segmentId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: ZContactLinksBySegmentParams,
query: ZContactLinksBySegmentQuery,
},
handler: async ({ authentication, parsedInput }) => {
const { params, query } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
],
});
}
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
}
// Get contacts based on segment
const contactsResult = await getContactsInSegment(
params.surveyId,
params.segmentId,
query?.limit || 10,
query?.skip || 0,
query?.attributeKeys
);
if (!contactsResult.ok) {
return handleApiError(request, contactsResult.error);
}
const { data: contacts, meta } = contactsResult.data;
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
expiresAt = expirationDate.toISOString();
}
// Generate survey links for each contact
const contactLinks = contacts
.map((contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(
contactId,
params.surveyId,
query?.expirationDays || undefined
);
if (!surveyUrlResult.ok) {
logger.error(
{ error: surveyUrlResult.error, contactId: contactId, surveyId: params.surveyId },
"Failed to generate survey URL for contact"
);
return null;
}
return {
contactId,
attributes,
surveyUrl: surveyUrlResult.data,
expiresAt,
};
})
.filter(Boolean);
return responses.successResponse({
data: contactLinks,
meta,
});
},
});

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { z } from "zod";
export const ZGetFilter = z.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
limit: z.coerce.number().min(1).max(250).optional().default(50).describe("Number of items to return"),
skip: z.coerce.number().min(0).optional().default(0).describe("Number of items to skip"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt").describe("Sort by field"),
order: z.enum(["asc", "desc"]).optional().default("desc").describe("Sort order"),
startDate: z.coerce.date().optional().describe("Start date"),
endDate: z.coerce.date().optional().describe("End date"),
});
export type TGetFilter = z.infer<typeof ZGetFilter>;

View File

@@ -0,0 +1,377 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createUserAction } from "@/modules/auth/signup/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useSearchParams } from "next/navigation";
import toast from "react-hot-toast";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmailTokenAction } from "../../../auth/actions";
import { SignupForm } from "./signup-form";
// Mock dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
FB_LOGO_URL: "mock-fb-logo-url",
SMTP_HOST: "smtp.example.com",
SMTP_PORT: 587,
SMTP_USER: "smtp-user",
}));
// Set up a push mock for useRouter
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
useSearchParams: vi.fn(),
}));
vi.mock("react-turnstile", () => ({
useTurnstile: () => ({
reset: vi.fn(),
}),
default: (props: any) => (
<div
data-testid="turnstile"
onClick={() => {
if (props.onSuccess) {
props.onSuccess("test-turnstile-token");
}
}}
{...props}
/>
),
}));
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
toast: {
error: vi.fn(),
},
},
}));
vi.mock("@/modules/auth/signup/actions", () => ({
createUserAction: vi.fn(),
}));
vi.mock("../../../auth/actions", () => ({
createEmailTokenAction: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
// Mock components
vi.mock("@/modules/ee/sso/components/sso-options", () => ({
SSOOptions: () => <div data-testid="sso-options">SSOOptions</div>,
}));
vi.mock("@/modules/auth/signup/components/terms-privacy-links", () => ({
TermsPrivacyLinks: () => <div data-testid="terms-privacy-links">TermsPrivacyLinks</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: (props: any) => <button {...props}>{props.children}</button>,
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: (props: any) => <input {...props} />,
}));
vi.mock("@/modules/ui/components/password-input", () => ({
PasswordInput: (props: any) => <input type="password" {...props} />,
}));
const defaultProps = {
webAppUrl: "http://localhost",
privacyUrl: "http://localhost/privacy",
termsUrl: "http://localhost/terms",
emailAuthEnabled: true,
googleOAuthEnabled: false,
githubOAuthEnabled: false,
azureOAuthEnabled: false,
oidcOAuthEnabled: false,
userLocale: "en-US",
emailVerificationDisabled: false,
isSsoEnabled: false,
samlSsoEnabled: false,
isTurnstileConfigured: false,
samlTenant: "",
samlProduct: "",
defaultOrganizationId: "org1",
defaultOrganizationRole: "member",
turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false
} as const;
describe("SignupForm", () => {
afterEach(() => {
cleanup();
});
it("toggles the signup form on button click", () => {
render(<SignupForm {...defaultProps} />);
// Initially, the signup form is hidden.
try {
screen.getByTestId("signup-name");
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
// Click the button to reveal the signup form.
const toggleButton = screen.getByTestId("signup-show-login");
fireEvent.click(toggleButton);
// Now the input fields should appear.
expect(screen.getByTestId("signup-name")).toBeInTheDocument();
expect(screen.getByTestId("signup-email")).toBeInTheDocument();
expect(screen.getByTestId("signup-password")).toBeInTheDocument();
});
it("submits the form successfully", async () => {
// Set up mocks for the API actions.
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
render(<SignupForm {...defaultProps} />);
// Click the button to reveal the signup form.
const toggleButton = screen.getByTestId("signup-show-login");
fireEvent.click(toggleButton);
const nameInput = screen.getByTestId("signup-name");
const emailInput = screen.getByTestId("signup-email");
const passwordInput = screen.getByTestId("signup-password");
fireEvent.change(nameInput, { target: { value: "Test User" } });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "Password123" } });
const submitButton = screen.getByTestId("signup-submit");
fireEvent.submit(submitButton);
await waitFor(() => {
expect(createUserAction).toHaveBeenCalledWith({
name: "Test User",
email: "test@example.com",
password: "Password123",
userLocale: defaultProps.userLocale,
inviteToken: "",
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
defaultOrganizationId: defaultProps.defaultOrganizationId,
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
turnstileToken: undefined,
});
});
await waitFor(() => {
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
});
// Since email verification is enabled (emailVerificationDisabled is false),
// router.push should be called with the verification URL.
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
});
it("submits the form successfully when turnstile is configured", async () => {
// Override props to enable Turnstile
const props = {
...defaultProps,
isTurnstileConfigured: true,
turnstileSiteKey: "dummy",
emailVerificationDisabled: true,
};
// Set up mocks for the API actions
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
render(<SignupForm {...props} />);
// Click the button to reveal the signup form
const toggleButton = screen.getByTestId("signup-show-login");
fireEvent.click(toggleButton);
// Fill out the form fields
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
// Simulate receiving a turnstile token by clicking the Turnstile element.
const turnstileElement = screen.getByTestId("turnstile");
fireEvent.click(turnstileElement);
// Submit the form.
const submitButton = screen.getByTestId("signup-submit");
fireEvent.submit(submitButton);
await waitFor(() => {
expect(createUserAction).toHaveBeenCalledWith({
name: "Test User",
email: "test@example.com",
password: "Password123",
userLocale: props.userLocale,
inviteToken: "",
emailVerificationDisabled: true,
defaultOrganizationId: props.defaultOrganizationId,
defaultOrganizationRole: props.defaultOrganizationRole,
turnstileToken: "test-turnstile-token",
});
});
await waitFor(() => {
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
});
expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success");
});
it("submits the form successfully when turnstile is configured, but createEmailTokenAction don't return data", async () => {
// Override props to enable Turnstile
const props = {
...defaultProps,
isTurnstileConfigured: true,
turnstileSiteKey: "dummy",
emailVerificationDisabled: true,
};
// Set up mocks for the API actions
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
vi.mocked(createEmailTokenAction).mockResolvedValue(undefined);
vi.mocked(getFormattedErrorMessage).mockReturnValue("error");
render(<SignupForm {...props} />);
// Click the button to reveal the signup form
const toggleButton = screen.getByTestId("signup-show-login");
fireEvent.click(toggleButton);
// Fill out the form fields
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
// Simulate receiving a turnstile token by clicking the Turnstile element.
const turnstileElement = screen.getByTestId("turnstile");
fireEvent.click(turnstileElement);
// Submit the form.
const submitButton = screen.getByTestId("signup-submit");
fireEvent.submit(submitButton);
await waitFor(() => {
expect(createUserAction).toHaveBeenCalledWith({
name: "Test User",
email: "test@example.com",
password: "Password123",
userLocale: props.userLocale,
inviteToken: "",
emailVerificationDisabled: true,
defaultOrganizationId: props.defaultOrganizationId,
defaultOrganizationRole: props.defaultOrganizationRole,
turnstileToken: "test-turnstile-token",
});
});
// Since Turnstile is configured, but no token is received, an error message should be shown.
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("error");
});
});
it("shows an error message if turnstile is configured, but no token is received", async () => {
// Override props to enable Turnstile
const props = {
...defaultProps,
isTurnstileConfigured: true,
turnstileSiteKey: "dummy",
emailVerificationDisabled: true,
};
// Set up mocks for the API actions
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
render(<SignupForm {...props} />);
// Click the button to reveal the signup form
const toggleButton = screen.getByTestId("signup-show-login");
fireEvent.click(toggleButton);
// Fill out the form fields
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
// Submit the form.
const submitButton = screen.getByTestId("signup-submit");
fireEvent.submit(submitButton);
// Since Turnstile is configured, but no token is received, an error message should be shown.
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("auth.signup.please_verify_captcha");
});
});
it("Invite token is in the search params", async () => {
// Set up mocks for the API actions
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("inviteToken=token123") as any);
render(<SignupForm {...defaultProps} />);
// Click the button to reveal the signup form
const toggleButton = screen.getByTestId("signup-show-login");
fireEvent.click(toggleButton);
// Fill out the form fields
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
// Submit the form.
const submitButton = screen.getByTestId("signup-submit");
fireEvent.submit(submitButton);
// Check that the invite token is passed to the createUserAction
await waitFor(() => {
expect(createUserAction).toHaveBeenCalledWith({
name: "Test User",
email: "test@example.com",
password: "Password123",
userLocale: defaultProps.userLocale,
inviteToken: "token123",
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
defaultOrganizationId: defaultProps.defaultOrganizationId,
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
turnstileToken: undefined,
});
});
await waitFor(() => {
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
});
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
});
});

View File

@@ -19,7 +19,6 @@ import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Turnstile, { useTurnstile } from "react-turnstile";
import { z } from "zod";
import { env } from "@formbricks/lib/env";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { createEmailTokenAction } from "../../../auth/actions";
@@ -31,8 +30,6 @@ const ZSignupInput = z.object({
password: ZUserPassword,
});
const turnstileSiteKey = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
type TSignupInput = z.infer<typeof ZSignupInput>;
interface SignupFormProps {
@@ -55,6 +52,7 @@ interface SignupFormProps {
isTurnstileConfigured: boolean;
samlTenant: string;
samlProduct: string;
turnstileSiteKey?: string;
}
export const SignupForm = ({
@@ -77,6 +75,7 @@ export const SignupForm = ({
isTurnstileConfigured,
samlTenant,
samlProduct,
turnstileSiteKey,
}: SignupFormProps) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
@@ -171,10 +170,11 @@ export const SignupForm = ({
<FormControl>
<div>
<Input
data-testid="signup-name"
value={field.value}
name="name"
autoFocus
onChange={(name) => field.onChange(name)}
onChange={(e) => field.onChange(e.target.value)}
placeholder="Full name"
className="bg-white"
/>
@@ -192,9 +192,10 @@ export const SignupForm = ({
<FormControl>
<div>
<Input
data-testid="signup-email"
value={field.value}
name="email"
onChange={(email) => field.onChange(email)}
onChange={(e) => field.onChange(e.target.value)}
placeholder="work@email.com"
className="bg-white"
/>
@@ -212,10 +213,11 @@ export const SignupForm = ({
<FormControl>
<div>
<PasswordInput
data-testid="signup-password"
id="password"
name="password"
value={field.value}
onChange={(password) => field.onChange(password)}
onChange={(e) => field.onChange(e.target.value)}
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
@@ -248,6 +250,7 @@ export const SignupForm = ({
{showLogin && (
<Button
data-testid="signup-submit"
type="submit"
className="h-10 w-full justify-center"
loading={form.formState.isSubmitting}
@@ -258,6 +261,7 @@ export const SignupForm = ({
{!showLogin && (
<Button
data-testid="signup-show-login"
type="button"
onClick={() => {
setShowLogin(true);

View File

@@ -4,6 +4,7 @@ import {
getIsSamlSsoEnabled,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -50,23 +51,50 @@ vi.mock("next/navigation", () => ({
// Mock environment variables and constants
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
FB_LOGO_URL: "mock-fb-logo-url",
SMTP_HOST: "smtp.example.com",
SMTP_PORT: 587,
SMTP_USER: "smtp-user",
SAML_AUDIENCE: "test-saml-audience",
SAML_PATH: "test-saml-path",
SAML_DATABASE_URL: "test-saml-database-url",
TERMS_URL: "test-terms-url",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
PRIVACY_URL: "test-privacy-url",
EMAIL_VERIFICATION_DISABLED: false,
EMAIL_AUTH_ENABLED: true,
GOOGLE_OAUTH_ENABLED: true,
GITHUB_OAUTH_ENABLED: true,
AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true,
OIDC_DISPLAY_NAME: "OpenID",
SAML_OAUTH_ENABLED: true,
SAML_TENANT: "test-tenant",
SAML_PRODUCT: "test-product",
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
IS_TURNSTILE_CONFIGURED: true,
WEBAPP_URL: "http://localhost:3000",
TERMS_URL: "http://localhost:3000/terms",
PRIVACY_URL: "http://localhost:3000/privacy",
DEFAULT_ORGANIZATION_ID: "test-org-id",
DEFAULT_ORGANIZATION_ROLE: "admin",
SAML_TENANT: "test-saml-tenant",
SAML_PRODUCT: "test-saml-product",
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
SAML_OAUTH_ENABLED: true,
}));
describe("SignupPage", () => {
@@ -88,8 +116,11 @@ describe("SignupPage", () => {
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en");
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(verifyInviteToken).mockReturnValue({
inviteId: "test-invite-id",
email: "test@example.com",
});
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
const result = await SignupPage({ searchParams: mockSearchParams });
@@ -128,7 +159,10 @@ describe("SignupPage", () => {
it("calls notFound when invite token is valid but invite is not found", async () => {
// Mock the license check functions to return false
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
vi.mocked(verifyInviteToken).mockReturnValue({
inviteId: "test-invite-id",
email: "test@example.com",
});
vi.mocked(getIsValidInviteToken).mockResolvedValue(false);
await SignupPage({ searchParams: { inviteToken: "test-token" } });
@@ -141,8 +175,11 @@ describe("SignupPage", () => {
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en");
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(verifyInviteToken).mockReturnValue({
inviteId: "test-invite-id",
email: "test@example.com",
});
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
const result = await SignupPage({ searchParams: { email: "test@example.com" } });

View File

@@ -24,6 +24,7 @@ import {
SAML_TENANT,
SIGNUP_ENABLED,
TERMS_URL,
TURNSTILE_SITE_KEY,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { verifyInviteToken } from "@formbricks/lib/jwt";
@@ -83,6 +84,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
turnstileSiteKey={TURNSTILE_SITE_KEY}
/>
</FormWrapper>
</div>

View File

@@ -0,0 +1,291 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import {
TBaseFilters,
TSegmentAttributeFilter,
TSegmentDeviceFilter,
TSegmentFilter,
TSegmentPersonFilter,
TSegmentSegmentFilter,
} from "@formbricks/types/segment";
import { getSegment } from "../segments";
// Type for the result of the segment filter to prisma query generation
export type SegmentFilterQueryResult = {
whereClause: Prisma.ContactWhereInput;
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
const { operator } = qualifier;
// This base query checks if the contact has an attribute with the specified key
const baseQuery = {
attributes: {
some: {
attributeKey: {
key: contactAttributeKey,
},
},
},
};
// Handle special operators that don't require a value
if (operator === "isSet") {
return baseQuery;
}
if (operator === "isNotSet") {
return {
NOT: baseQuery,
};
}
// For all other operators, we need to check the attribute value
const valueQuery = {
attributes: {
some: {
attributeKey: {
key: contactAttributeKey,
},
value: {},
},
},
} satisfies Prisma.ContactWhereInput;
// Apply the appropriate operator to the attribute value
switch (operator) {
case "equals":
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
break;
case "notEquals":
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
break;
case "contains":
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
break;
case "doesNotContain":
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
break;
case "startsWith":
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
break;
case "endsWith":
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
break;
case "greaterThan":
valueQuery.attributes.some.value = { gt: String(value) };
break;
case "greaterEqual":
valueQuery.attributes.some.value = { gte: String(value) };
break;
case "lessThan":
valueQuery.attributes.some.value = { lt: String(value) };
break;
case "lessEqual":
valueQuery.attributes.some.value = { lte: String(value) };
break;
default:
valueQuery.attributes.some.value = String(value);
}
return valueQuery;
};
/**
* Builds a Prisma where clause from a person filter
*/
const buildPersonFilterWhereClause = (filter: TSegmentPersonFilter): Prisma.ContactWhereInput => {
const { personIdentifier } = filter.root;
if (personIdentifier === "userId") {
const personFilter: TSegmentAttributeFilter = {
...filter,
root: {
type: "attribute",
contactAttributeKey: personIdentifier,
},
};
return buildAttributeFilterWhereClause(personFilter);
}
return {};
};
/**
* Builds a Prisma where clause from a device filter
*/
const buildDeviceFilterWhereClause = (filter: TSegmentDeviceFilter): Prisma.ContactWhereInput => {
const { root, qualifier, value } = filter;
const { type } = root;
const { operator } = qualifier;
const baseQuery = {
attributes: {
some: {
attributeKey: {
key: type,
},
value: {},
},
},
} satisfies Prisma.ContactWhereInput;
if (operator === "equals") {
baseQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
} else if (operator === "notEquals") {
baseQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
}
return baseQuery;
};
/**
* Builds a Prisma where clause from a segment filter
*/
const buildSegmentFilterWhereClause = async (
filter: TSegmentSegmentFilter,
segmentPath: Set<string>
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
const { segmentId } = root;
if (segmentPath.has(segmentId)) {
logger.error(
{ segmentId, path: Array.from(segmentPath) },
"Circular reference detected in segment filter"
);
return {};
}
const segment = await getSegment(segmentId);
if (!segment) {
logger.error({ segmentId }, "Segment not found");
return {};
}
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath);
};
/**
* Recursively processes a segment filter or group and returns a Prisma where clause
*/
const processSingleFilter = async (
filter: TSegmentFilter,
segmentPath: Set<string>
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
switch (root.type) {
case "attribute":
return buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
case "person":
return buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
case "device":
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter);
case "segment":
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath);
default:
return {};
}
};
/**
* Recursively processes filters and returns a combined Prisma where clause
*/
const processFilters = async (
filters: TBaseFilters,
segmentPath: Set<string>
): Promise<Prisma.ContactWhereInput> => {
if (filters.length === 0) return {};
const query: { AND: Prisma.ContactWhereInput[]; OR: Prisma.ContactWhereInput[] } = {
AND: [],
OR: [],
};
for (let i = 0; i < filters.length; i++) {
const { resource, connector } = filters[i];
let whereClause: Prisma.ContactWhereInput;
// Process the resource based on its type
if (isResourceFilter(resource)) {
// If it's a single filter, process it directly
whereClause = await processSingleFilter(resource, segmentPath);
} else {
// If it's a group of filters, process it recursively
whereClause = await processFilters(resource, segmentPath);
}
if (Object.keys(whereClause).length === 0) continue;
if (filters.length === 1) query.AND = [whereClause];
else {
if (i === 0) {
if (filters[1].connector === "and") query.AND.push(whereClause);
else query.OR.push(whereClause);
} else {
if (connector === "and") query.AND.push(whereClause);
else query.OR.push(whereClause);
}
}
}
return {
...(query.AND.length > 0 ? { AND: query.AND } : {}),
...(query.OR.length > 0 ? { OR: query.OR } : {}),
};
};
/**
* Transforms a segment filter into a Prisma query for contacts
*/
export const segmentFilterToPrismaQuery = reactCache(
async (segmentId: string, filters: TBaseFilters, environmentId: string) =>
cache(
async (): Promise<Result<SegmentFilterQueryResult, ApiErrorResponseV2>> => {
try {
const baseWhereClause = {
environmentId,
};
// Initialize an empty stack for tracking the current evaluation path
const segmentPath = new Set<string>([segmentId]);
const filtersWhereClause = await processFilters(filters, segmentPath);
const whereClause = {
AND: [baseWhereClause, filtersWhereClause],
};
return ok({ whereClause });
} catch (error) {
logger.error(
{ error, segmentId, environmentId },
"Error transforming segment filter to Prisma query"
);
return err({
type: "bad_request",
message: "Failed to convert segment filters to Prisma query",
details: [{ field: "segment", issue: "Invalid segment filters" }],
});
}
},
[`segmentFilterToPrismaQuery-${segmentId}-${environmentId}-${JSON.stringify(filters)}`],
{
tags: [segmentCache.tag.byEnvironmentId(environmentId), segmentCache.tag.byId(segmentId)],
}
)()
);

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -15,7 +16,7 @@ import { Modal } from "@/modules/ui/components/modal";
import { Switch } from "@/modules/ui/components/switch";
import { ApiKeyPermission } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { AlertTriangleIcon, ChevronDownIcon, Trash2Icon } from "lucide-react";
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
import { Fragment, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
@@ -389,11 +390,9 @@ export const AddApiKeyModal = ({
</div>
</div>
</div>
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
<p>{t("environments.project.api_keys.api_key_security_warning")}</p>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
</Alert>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">

View File

@@ -6,7 +6,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import packageJson from "@/package.json";
import { getTranslate } from "@/tolgee/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getProjects } from "@formbricks/lib/project/service";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
@@ -51,7 +51,7 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
</SettingsCard>
<div>
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
{!IS_FORMBRICKS_CLOUD && (
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
)}
</div>

View File

@@ -0,0 +1,97 @@
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { SignupPage } from "./page";
// Mock dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
FB_LOGO_URL: "mock-fb-logo-url",
SMTP_HOST: "smtp.example.com",
SMTP_PORT: 587,
SMTP_USER: "smtp-user",
SAML_AUDIENCE: "test-saml-audience",
SAML_PATH: "test-saml-path",
SAML_DATABASE_URL: "test-saml-database-url",
TERMS_URL: "test-terms-url",
SIGNUP_ENABLED: true,
PRIVACY_URL: "test-privacy-url",
EMAIL_VERIFICATION_DISABLED: false,
EMAIL_AUTH_ENABLED: true,
GOOGLE_OAUTH_ENABLED: true,
GITHUB_OAUTH_ENABLED: true,
AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true,
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
IS_TURNSTILE_CONFIGURED: true,
SAML_TENANT: "test-saml-tenant",
SAML_PRODUCT: "test-saml-product",
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getisSsoEnabled: vi.fn(),
getIsSamlSsoEnabled: vi.fn(),
}));
vi.mock("@formbricks/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
// Mock the SignupForm component to simplify our test assertions
vi.mock("@/modules/auth/signup/components/signup-form", () => ({
SignupForm: (props) => (
<div data-testid="signup-form" data-turnstile-key={props.turnstileSiteKey}>
SignupForm
</div>
),
}));
describe("SignupPage", () => {
beforeEach(() => {
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getTranslate).mockResolvedValue((key) => key);
});
it("renders the signup page correctly", async () => {
const page = await SignupPage();
render(page);
expect(screen.getByTestId("signup-form")).toBeInTheDocument();
expect(screen.getByTestId("signup-form")).toHaveAttribute(
"data-turnstile-key",
"test-turnstile-site-key"
);
});
});

View File

@@ -18,6 +18,7 @@ import {
SAML_PRODUCT,
SAML_TENANT,
TERMS_URL,
TURNSTILE_SITE_KEY,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
@@ -59,6 +60,7 @@ export const SignupPage = async () => {
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
turnstileSiteKey={TURNSTILE_SITE_KEY}
/>
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { getDefaultEndingCard } from "@/app/lib/templates";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Label } from "@/modules/ui/components/label";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
@@ -8,8 +9,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Environment } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
import Link from "next/link";
import { CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TSegment } from "@formbricks/types/segment";
@@ -106,7 +106,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
className="h-full w-full cursor-pointer"
id="howToSendCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<div className="flex items-center pr-5 pl-2">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -171,23 +171,25 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
</div>
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
{localSurvey.type === option.id && option.alert && (
<div className="mt-2 flex items-center space-x-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2">
<AlertCircleIcon className="h-5 w-5 text-amber-500" />
<div className="text-amber-800">
<p className="text-xs font-semibold">
{t("environments.surveys.edit.formbricks_sdk_is_not_connected")}
</p>
<p className="text-xs font-normal">
<Link
href={`/environments/${environment.id}/project/${option.id}-connection`}
className="underline hover:text-amber-900"
target="_blank">
{t("common.connect_formbricks")}
</Link>{" "}
{t("environments.surveys.edit.and_launch_surveys_in_your_website_or_app")}
</p>
</div>
</div>
<Alert variant="warning" className="mt-2">
<AlertTitle>
{t("environments.surveys.edit.formbricks_sdk_is_not_connected")}
</AlertTitle>
<AlertDescription>
{t("common.connect_formbricks") +
" " +
t("environments.surveys.edit.and_launch_surveys_in_your_website_or_app")}
</AlertDescription>
<AlertButton
onClick={() =>
window.open(
`/environments/${environment.id}/project/${option.id}-connection`,
"_blank"
)
}>
{t("common.connect_formbricks")}
</AlertButton>
</Alert>
)}
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
} from "@/modules/survey/editor/types/survey-follow-up";
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Editor } from "@/modules/ui/components/editor";
@@ -423,17 +424,13 @@ export const FollowUpModal = ({
</Select>
{triggerType === "endings" && !localSurvey.endings.length ? (
<div className="mt-4 flex items-start text-yellow-600">
<TriangleAlertIcon
className="mr-2 h-5 min-h-5 w-5 min-w-5"
aria-hidden="true"
/>
<p className="text-sm">
<Alert variant="warning" size="small">
<AlertTitle>
{t(
"environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"
)}
</p>
</div>
</AlertTitle>
</Alert>
) : null}
</div>
</div>

View File

@@ -1,74 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "./index";
describe("Alert", () => {
it("renders with default variant", () => {
render(
<Alert>
<AlertTitle>Test Title</AlertTitle>
<AlertDescription>Test Description</AlertDescription>
</Alert>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("Test Title")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
});
it("renders with different variants", () => {
const variants = ["default", "error", "warning", "info", "success"] as const;
variants.forEach((variant) => {
const { container } = render(
<Alert variant={variant}>
<AlertTitle>Test Title</AlertTitle>
</Alert>
);
expect(container.firstChild).toHaveClass(
variant === "default" ? "text-foreground" : `text-${variant}-foreground`
);
});
});
it("renders with different sizes", () => {
const sizes = ["default", "small"] as const;
sizes.forEach((size) => {
const { container } = render(
<Alert size={size}>
<AlertTitle>Test Title</AlertTitle>
</Alert>
);
expect(container.firstChild).toHaveClass(size === "default" ? "py-3" : "py-2");
});
});
it("renders with button and handles click", () => {
const handleClick = vi.fn();
render(
<Alert>
<AlertTitle>Test Title</AlertTitle>
<AlertButton onClick={handleClick}>Click me</AlertButton>
</Alert>
);
const button = screen.getByText("Click me");
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("applies custom className", () => {
const { container } = render(
<Alert className="custom-class">
<AlertTitle>Test Title</AlertTitle>
</Alert>
);
expect(container.firstChild).toHaveClass("custom-class");
});
});

View File

@@ -21,23 +21,23 @@ const AlertContext = createContext<AlertContextValue>({
const useAlertContext = () => useContext(AlertContext);
// Define alert styles with variants
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 [&>svg]:text-foreground", {
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4", {
variants: {
variant: {
default: "text-foreground border-border",
error:
"text-error-foreground border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted",
"text-error-foreground [&>svg]:text-error border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted",
warning:
"text-warning-foreground border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted",
info: "text-info-foreground border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted",
"text-warning-foreground [&>svg]:text-warning border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted",
info: "text-info-foreground [&>svg]:text-info border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted",
success:
"text-success-foreground border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted",
"text-success-foreground [&>svg]:text-success border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted",
},
size: {
default:
"py-3 px-4 text-sm grid grid-cols-[1fr_auto] grid-rows-[auto_auto] gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
"py-3 px-4 text-sm grid grid-cols-[2fr_auto] grid-rows-[auto_auto] gap-y-0.5 gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
small:
"px-3 py-2 text-xs flex items-center justify-between gap-2 [&>svg]:flex-shrink-0 [&_button]:text-xs [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
},
},
defaultVariants: {
@@ -58,7 +58,7 @@ const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, size, ...props }, ref) => {
const variantIcon = variant ? (variant !== "default" ? alertVariantIcons[variant] : null) : null;
const variantIcon = variant && variant !== "default" ? alertVariantIcons[variant] : null;
return (
<AlertContext.Provider value={{ variant, size }}>
@@ -78,8 +78,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
<h5
ref={ref}
className={cn(
"col-start-1 row-start-1 font-medium leading-none tracking-tight",
size === "small" ? "min-w-0 flex-shrink truncate" : "col-start-1 row-start-1",
"col-start-1 row-start-1 font-medium tracking-tight",
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
className
)}
{...props}
@@ -99,9 +99,7 @@ const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttrib
ref={ref}
className={cn(
"[&_p]:leading-relaxed",
size === "small"
? "hidden min-w-0 flex-shrink flex-grow truncate opacity-80 sm:block" // Hidden on very small screens, limited width
: "col-start-1 row-start-2",
size === "small" ? "flex-shrink flex-grow-0 truncate" : "col-start-1 row-start-2",
className
)}
{...props}
@@ -124,7 +122,7 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(
"self-end",
alertSize === "small"
? "-my-2 -mr-3 ml-auto flex-shrink-0"
? "-my-2 -mr-4 ml-auto flex-shrink-0"
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
)}>
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>

View File

@@ -1,62 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Default, Error, Info, Small, Success, Warning, withButtonAndIcon } from "./stories";
describe("Alert Stories", () => {
const renderStory = (Story: any) => {
return render(Story.render(Story.args));
};
afterEach(() => {
cleanup();
});
it("renders Default story", () => {
renderStory(Default);
expect(screen.getByText("Alert Title")).toBeInTheDocument();
expect(screen.getByText("This is an important notification.")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders Small story", () => {
renderStory(Small);
expect(screen.getByText("Information Alert")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByText("Learn more")).toBeInTheDocument();
});
it("renders withButtonAndIcon story", () => {
renderStory(withButtonAndIcon);
expect(screen.getByText("Alert Title")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByText("Learn more")).toBeInTheDocument();
});
it("renders Error story", () => {
renderStory(Error);
expect(screen.getByText("Error Alert")).toBeInTheDocument();
expect(screen.getByText("Your session has expired. Please log in again.")).toBeInTheDocument();
expect(screen.getByText("Log in")).toBeInTheDocument();
});
it("renders Warning story", () => {
renderStory(Warning);
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
expect(screen.getByText("You are editing sensitive data. Be cautious")).toBeInTheDocument();
expect(screen.getByText("Proceed")).toBeInTheDocument();
});
it("renders Info story", () => {
renderStory(Info);
expect(screen.getByText("Info Alert")).toBeInTheDocument();
expect(screen.getByText("There was an update to your application.")).toBeInTheDocument();
expect(screen.getByText("Refresh")).toBeInTheDocument();
});
it("renders Success story", () => {
renderStory(Success);
expect(screen.getByText("Success Alert")).toBeInTheDocument();
expect(screen.getByText("This worked! Please proceed.")).toBeInTheDocument();
expect(screen.getByText("Close")).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { getTranslate } from "@/tolgee/server";
import { LightbulbIcon } from "lucide-react";
import Link from "next/link";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
@@ -19,19 +20,19 @@ export const EnvironmentNotice = async ({ environmentId, subPageUrl }: Environme
const otherEnvironmentId = environments.filter((e) => e.id !== environment.id)[0].id;
return (
<div className="mt-4 flex max-w-4xl items-center space-y-4 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<LightbulbIcon className="mr-3 h-4 w-4 text-blue-400" />
<p className="text-sm">
{t("common.environment_notice", { environment: environment.type })}
<a
href={`${WEBAPP_URL}/environments/${otherEnvironmentId}${subPageUrl}`}
className="ml-1 cursor-pointer text-sm underline">
{t("common.switch_to", {
environment: environment.type === "production" ? "Development" : "Production",
})}
.
</a>
</p>
<div>
<Alert variant="info" size="small" className="max-w-4xl">
<AlertTitle>{t("common.environment_notice", { environment: environment.type })}</AlertTitle>
<AlertButton>
<Link
href={`${WEBAPP_URL}/environments/${otherEnvironmentId}${subPageUrl}`}
className="ml-1 cursor-pointer underline">
{t("common.switch_to", {
environment: environment.type === "production" ? "Development" : "Production",
})}
</Link>
</AlertButton>
</Alert>
</div>
);
};

View File

@@ -1,10 +1,10 @@
"use client";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { checkForYoutubeUrl, convertToEmbedUrl, extractYoutubeId } from "@formbricks/lib/utils/videoUpload";
@@ -112,10 +112,9 @@ export const VideoSettings = ({
</div>
{showPlatformWarning && (
<div className="flex items-center space-x-2 rounded-md border bg-slate-100 p-2 text-xs text-slate-600">
<AlertTriangle className="h-6 w-6" />
<p>{t("environments.surveys.edit.invalid_video_url_warning")}</p>
</div>
<Alert variant="warning" size="small">
<AlertTitle>{t("environments.surveys.edit.invalid_video_url_warning")}</AlertTitle>
</Alert>
)}
{isYoutubeLink && (

View File

@@ -135,7 +135,7 @@ const nextConfig = {
{
key: "Access-Control-Allow-Headers",
value:
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
},
],
},
@@ -149,7 +149,7 @@ const nextConfig = {
{
key: "Access-Control-Allow-Headers",
value:
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
},
],
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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