Compare commits

...

7 Commits

Author SHA1 Message Date
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
55 changed files with 3803 additions and 159 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

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

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

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

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

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

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

@@ -162,7 +162,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 +207,7 @@
"SURVEY_URL",
"TELEMETRY_DISABLED",
"TURNSTILE_SECRET_KEY",
"TURNSTILE_SITE_KEY",
"TERMS_URL",
"UPLOADS_DIR",
"VERCEL",