mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 14:10:45 -06:00
Compare commits
7 Commits
fix-user-i
...
tweak-publ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f276f0095 | ||
|
|
81fc97c7e9 | ||
|
|
785c5a59c6 | ||
|
|
25ecfaa883 | ||
|
|
38e2c019fa | ||
|
|
15878a4ac5 | ||
|
|
9802536ded |
@@ -117,7 +117,7 @@ IMPRINT_URL=
|
||||
IMPRINT_ADDRESS=
|
||||
|
||||
# Configure Turnstile in signup flow
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Configure Github Login
|
||||
|
||||
@@ -27,7 +27,7 @@ const secondaryNavigation = [
|
||||
|
||||
export function Sidebar(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
|
||||
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
|
||||
<nav
|
||||
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
|
||||
aria-label="Sidebar">
|
||||
@@ -41,7 +41,7 @@ export function Sidebar(): React.JSX.Element {
|
||||
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
|
||||
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@tailwindcss/forms": "0.5.9",
|
||||
"@tailwindcss/postcss": "4.1.3",
|
||||
"lucide-react": "0.486.0",
|
||||
"next": "15.2.4",
|
||||
"postcss": "8.5.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"tailwindcss": "3.4.16"
|
||||
"tailwindcss": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function AppPage(): React.JSX.Element {
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
|
||||
</p>
|
||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
|
||||
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
|
||||
|
||||
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
|
||||
<p className="mb-1 sm:mb-0 sm:mr-2">You're connected with env:</p>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membershipRole}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isLicenseActive={active}
|
||||
|
||||
@@ -63,6 +63,7 @@ interface NavigationProps {
|
||||
projects: TProject[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
@@ -79,6 +80,7 @@ export const MainNavigation = ({
|
||||
isFormbricksCloud,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isDevelopment,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -296,7 +298,7 @@ export const MainNavigation = ({
|
||||
|
||||
<div>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// PosthogIdentify.test.tsx
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -33,12 +33,16 @@ vi.mock("@formbricks/lib/constants", () => ({
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-ai",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
|
||||
|
||||
export { GET };
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SentryProvider } from "./SentryProvider";
|
||||
|
||||
vi.mock("@sentry/nextjs", async () => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getContactAttributeKeys = reactCache((environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const contactAttributeKeys = await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
const keys = contactAttributeKeys.map((key) => key.key);
|
||||
return ok(keys);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "contact attribute keys", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`getContactAttributeKeys-contact-links-${environmentId}`],
|
||||
{
|
||||
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
|
||||
import { getSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment";
|
||||
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys";
|
||||
import { TContactWithAttributes } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getContactsInSegment = reactCache(
|
||||
(surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<ApiResponseWithMeta<TContactWithAttributes[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const surveyResult = await getSurvey(surveyId);
|
||||
if (!surveyResult.ok) {
|
||||
return err(surveyResult.error);
|
||||
}
|
||||
|
||||
const survey = surveyResult.data;
|
||||
|
||||
if (survey.type !== "link" || survey.status !== "inProgress") {
|
||||
logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress");
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
return err(error);
|
||||
}
|
||||
|
||||
const segmentResult = await getSegment(segmentId);
|
||||
if (!segmentResult.ok) {
|
||||
return err(segmentResult.error);
|
||||
}
|
||||
|
||||
const segment = segmentResult.data;
|
||||
|
||||
if (survey.environmentId !== segment.environmentId) {
|
||||
logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment");
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "bad_request",
|
||||
details: [{ field: "segmentId", issue: "Environment mismatch" }],
|
||||
};
|
||||
return err(error);
|
||||
}
|
||||
|
||||
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
|
||||
segment.id,
|
||||
segment.filters,
|
||||
segment.environmentId
|
||||
);
|
||||
|
||||
if (!segmentFilterToPrismaQueryResult.ok) {
|
||||
return err(segmentFilterToPrismaQueryResult.error);
|
||||
}
|
||||
|
||||
const { whereClause } = segmentFilterToPrismaQueryResult.data;
|
||||
|
||||
const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId);
|
||||
if (!contactAttributeKeysResult.ok) {
|
||||
return err(contactAttributeKeysResult.error);
|
||||
}
|
||||
|
||||
const allAttributeKeys = contactAttributeKeysResult.data;
|
||||
|
||||
const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim());
|
||||
const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field));
|
||||
|
||||
const allowedAttributes = attributesToInclude.slice(0, 20);
|
||||
|
||||
const [totalContacts, contacts] = await prisma.$transaction([
|
||||
prisma.contact.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
|
||||
prisma.contact.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: allowedAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
skip: skip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const contactsWithAttributes = contacts.map((contact) => {
|
||||
const attributes = contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
return {
|
||||
contactId: contact.id,
|
||||
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return ok({
|
||||
data: contactsWithAttributes,
|
||||
meta: {
|
||||
total: totalContacts,
|
||||
limit: limit,
|
||||
offset: skip,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment");
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
};
|
||||
return err(apiError);
|
||||
}
|
||||
},
|
||||
[`getContactsInSegment-${surveyId}-${segmentId}-${attributeKeys}-${limit}-${skip}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byId(segmentId), surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
ZContactLinkResponse,
|
||||
ZContactLinksBySegmentParams,
|
||||
ZContactLinksBySegmentQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
|
||||
export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactLinksBySegment",
|
||||
summary: "Get survey links for contacts in a segment",
|
||||
description: "Generates personalized survey links for contacts in a segment.",
|
||||
tags: ["Management API > Surveys > Contact Links"],
|
||||
requestParams: {
|
||||
path: ZContactLinksBySegmentParams,
|
||||
query: ZContactLinksBySegmentQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact links generated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZContactLinkResponse))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Segment } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getSegment = reactCache(async (segmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Pick<Segment, "id" | "environmentId" | "filters">, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const segment = await prisma.segment.findUnique({
|
||||
where: { id: segmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(segment);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`contact-link-getSegment-${segmentId}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byId(segmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Survey } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getSurvey = reactCache(async (surveyId: string) =>
|
||||
cache(
|
||||
async (): Promise<
|
||||
Result<Pick<Survey, "id" | "environmentId" | "type" | "status">, ApiErrorResponseV2>
|
||||
> => {
|
||||
try {
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!survey) {
|
||||
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(survey);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`contact-link-getSurvey-${surveyId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getContactAttributeKeys", () => {
|
||||
const mockEnvironmentId = "mock-env-123";
|
||||
const mockContactAttributeKeys = [{ key: "email" }, { key: "name" }, { key: "userId" }];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("successfully retrieves contact attribute keys", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockContactAttributeKeys);
|
||||
|
||||
const result = await getContactAttributeKeys(mockEnvironmentId);
|
||||
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: mockEnvironmentId },
|
||||
select: { key: true },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(["email", "name", "userId"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles database error gracefully", async () => {
|
||||
const mockError = new Error("Database error");
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(mockError);
|
||||
|
||||
const result = await getContactAttributeKeys(mockEnvironmentId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "contact attribute keys", issue: mockError.message }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { SurveyStatus, SurveyType } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { TBaseFilters } from "@formbricks/types/segment";
|
||||
import { getContactsInSegment } from "../contact";
|
||||
import { getSegment } from "../segment";
|
||||
import { getSurvey } from "../surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../segment", () => ({
|
||||
getSegment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../surveys", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getContactsInSegment", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockSegmentId = "segment-456";
|
||||
const mockLimit = 10;
|
||||
const mockSkip = 0;
|
||||
const mockEnvironmentId = "env-789";
|
||||
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
type: "link" as SurveyType,
|
||||
status: "inProgress" as SurveyStatus,
|
||||
};
|
||||
|
||||
// Define filters as a TBaseFilters array with correct structure
|
||||
const mockFilters: TBaseFilters = [
|
||||
{
|
||||
id: "filter-1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "resource-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
value: "test@example.com",
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockSegment = {
|
||||
id: mockSegmentId,
|
||||
environmentId: mockEnvironmentId,
|
||||
filters: mockFilters,
|
||||
};
|
||||
|
||||
const mockContacts = [
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "another@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Another User" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSurvey,
|
||||
});
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSegment,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([{ key: "email" }, { key: "name" }]);
|
||||
|
||||
vi.mocked(prisma.contact.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return contacts when all operations succeed", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
|
||||
const attributeKeys = "email,name";
|
||||
const result = await getContactsInSegment(
|
||||
mockSurveyId,
|
||||
mockSegmentId,
|
||||
mockLimit,
|
||||
mockSkip,
|
||||
attributeKeys
|
||||
);
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{
|
||||
environmentId: "env-789",
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
},
|
||||
value: { equals: "test@example.com", mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.contact.count).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
});
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["email", "name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: mockLimit,
|
||||
skip: mockSkip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should filter contact attributes when fields parameter is provided", async () => {
|
||||
const filteredMockContacts = [
|
||||
{
|
||||
id: "contact-1",
|
||||
attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }],
|
||||
},
|
||||
{
|
||||
id: "contact-2",
|
||||
attributes: [{ attributeKey: { key: "email" }, value: "another@example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([filteredMockContacts.length, filteredMockContacts]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email");
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{
|
||||
environmentId: "env-789",
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
},
|
||||
value: { equals: "test@example.com", mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
|
||||
expect(prisma.contact.count).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
});
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["email"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: mockLimit,
|
||||
skip: mockSkip,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle multiple fields when fields parameter has comma-separated values", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email,name");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{
|
||||
contactId: "contact-1",
|
||||
attributes: {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
attributes: {
|
||||
email: "another@example.com",
|
||||
name: "Another User",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return no attributes but still return contacts when fields parameter is empty", async () => {
|
||||
const mockContactsWithoutAttributes = mockContacts.map((contact) => ({
|
||||
...contact,
|
||||
attributes: [],
|
||||
}));
|
||||
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([
|
||||
mockContactsWithoutAttributes.length,
|
||||
mockContactsWithoutAttributes,
|
||||
]);
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: mockContacts.map((contact) => ({
|
||||
contactId: contact.id,
|
||||
})),
|
||||
meta: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not a link survey", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
...mockSurvey,
|
||||
type: "web" as SurveyType,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not active", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "surveyId", issue: "Invalid survey" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
...mockSurvey,
|
||||
status: "completed" as SurveyStatus,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey is not found", async () => {
|
||||
const surveyError: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
ok: false,
|
||||
error: surveyError,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(surveyError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when segment is not found", async () => {
|
||||
const segmentError: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "segment", issue: "not found" }],
|
||||
};
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: false,
|
||||
error: segmentError,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).not.toHaveBeenCalled();
|
||||
expect(prisma.contact.findMany).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(segmentError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when survey and segment are in different environments", async () => {
|
||||
const mockSegmentWithDifferentEnv = {
|
||||
...mockSegment,
|
||||
environmentId: "different-env",
|
||||
};
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockSegmentWithDifferentEnv,
|
||||
});
|
||||
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).not.toHaveBeenCalled();
|
||||
expect(prisma.contact.findMany).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
details: [{ field: "segmentId", issue: "Environment mismatch" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when database operation fails", async () => {
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.contact.count).mockRejectedValue(dbError);
|
||||
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
|
||||
expect(prisma.contact.count).toHaveBeenCalled();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Segment } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { getSegment } from "../segment";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
segment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache/segment", () => ({
|
||||
segmentCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `segment-${id}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getSegment", () => {
|
||||
const mockSegmentId = "segment-123";
|
||||
const mockSegment: Pick<Segment, "id" | "environmentId" | "filters"> = {
|
||||
id: mockSegmentId,
|
||||
environmentId: "env-123",
|
||||
filters: [
|
||||
{
|
||||
id: "filter-123",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "attr_1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
value: "test@example.com",
|
||||
qualifier: { operator: "equals" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return segment data when segment is found", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSegmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockSegment);
|
||||
}
|
||||
|
||||
expect(segmentCache.tag.byId).toHaveBeenCalledWith(mockSegmentId);
|
||||
});
|
||||
|
||||
test("should return not_found error when segment doesn't exist", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSegmentId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
filters: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "segment", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error when database throws an error", async () => {
|
||||
const mockError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.segment.findUnique).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await getSegment(mockSegmentId);
|
||||
|
||||
expect(prisma.segment.findUnique).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "segment", issue: "Database connection failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should use correct cache key", async () => {
|
||||
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
|
||||
|
||||
await getSegment(mockSegmentId);
|
||||
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSegment-${mockSegmentId}`], {
|
||||
tags: [`segment-${mockSegmentId}`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSurvey } from "../surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/survey/cache", () => ({
|
||||
surveyCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `survey-${id}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getSurvey", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockEnvironmentId = "env-456";
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return survey data when survey is found", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSurveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockSurvey);
|
||||
}
|
||||
|
||||
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
|
||||
tags: [`survey-${mockSurveyId}`],
|
||||
});
|
||||
});
|
||||
|
||||
test("should return not_found error when survey doesn't exist", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSurveyId },
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error when database throws an error", async () => {
|
||||
const mockError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await getSurvey(mockSurveyId);
|
||||
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "survey", issue: "Database connection failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should use correct cache key and tags", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
|
||||
|
||||
await getSurvey(mockSurveyId);
|
||||
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
|
||||
tags: [`survey-${mockSurveyId}`],
|
||||
});
|
||||
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
|
||||
import {
|
||||
ZContactLinksBySegmentParams,
|
||||
ZContactLinksBySegmentQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ surveyId: string; segmentId: string }> }
|
||||
) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: ZContactLinksBySegmentParams,
|
||||
query: ZContactLinksBySegmentQuery,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params, query } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return handleApiError(request, {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
// Get contacts based on segment
|
||||
const contactsResult = await getContactsInSegment(
|
||||
params.surveyId,
|
||||
params.segmentId,
|
||||
query?.limit || 10,
|
||||
query?.skip || 0,
|
||||
query?.attributeKeys
|
||||
);
|
||||
|
||||
if (!contactsResult.ok) {
|
||||
return handleApiError(request, contactsResult.error);
|
||||
}
|
||||
|
||||
const { data: contacts, meta } = contactsResult.data;
|
||||
|
||||
// Calculate expiration date based on expirationDays
|
||||
let expiresAt: string | null = null;
|
||||
if (query?.expirationDays) {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
|
||||
expiresAt = expirationDate.toISOString();
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contacts
|
||||
.map((contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(
|
||||
contactId,
|
||||
params.surveyId,
|
||||
query?.expirationDays || undefined
|
||||
);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
logger.error(
|
||||
{ error: surveyUrlResult.error, contactId: contactId, surveyId: params.surveyId },
|
||||
"Failed to generate survey URL for contact"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
contactId,
|
||||
attributes,
|
||||
surveyUrl: surveyUrlResult.data,
|
||||
expiresAt,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return responses.successResponse({
|
||||
data: contactLinks,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZContactLinksBySegmentParams = z.object({
|
||||
surveyId: z.string().cuid2().describe("The ID of the survey"),
|
||||
segmentId: z.string().cuid2().describe("The ID of the segment"),
|
||||
});
|
||||
|
||||
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
|
||||
limit: true,
|
||||
skip: true,
|
||||
}).extend({
|
||||
expirationDays: z.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.nullish()
|
||||
.default(null)
|
||||
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
|
||||
attributeKeys: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated list of contact attribute keys to include in the response. You can have max 20 keys. If not provided, no attributes will be included."
|
||||
)
|
||||
.refine((fields) => {
|
||||
if (!fields) return true;
|
||||
const fieldsArray = fields.split(",");
|
||||
return fieldsArray.length <= 20;
|
||||
}, "You can have max 20 keys."),
|
||||
});
|
||||
|
||||
export type TContactWithAttributes = {
|
||||
contactId: string;
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const ZContactLinkResponse = z.object({
|
||||
contactId: z.string().describe("The ID of the contact"),
|
||||
surveyUrl: z.string().url().describe("Personalized survey link"),
|
||||
expiresAt: z.string().nullable().describe("The date and time the link expires, null if no expiration"),
|
||||
attributes: z.record(z.string(), z.string()).describe("The attributes of the contact"),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
|
||||
import { ZodOpenApiPathsObject } from "zod-openapi";
|
||||
|
||||
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
|
||||
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
|
||||
get: getContactLinksBySegmentEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-at
|
||||
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
|
||||
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
|
||||
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
|
||||
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
|
||||
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
|
||||
import { mePaths } from "@/modules/api/v2/me/lib/openapi";
|
||||
@@ -43,6 +44,7 @@ const document = createDocument({
|
||||
...contactAttributePaths,
|
||||
...contactAttributeKeyPaths,
|
||||
...surveyPaths,
|
||||
...surveyContactLinksBySegmentPaths,
|
||||
...webhookPaths,
|
||||
...teamPaths,
|
||||
...projectTeamPaths,
|
||||
@@ -83,6 +85,10 @@ const document = createDocument({
|
||||
name: "Management API > Surveys",
|
||||
description: "Operations for managing surveys.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Surveys > Contact Links",
|
||||
description: "Operations for generating personalized survey links for contacts.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Webhooks",
|
||||
description: "Operations for managing webhooks.",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetFilter = z.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
limit: z.coerce.number().min(1).max(250).optional().default(50).describe("Number of items to return"),
|
||||
skip: z.coerce.number().min(0).optional().default(0).describe("Number of items to skip"),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt").describe("Sort by field"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc").describe("Sort order"),
|
||||
startDate: z.coerce.date().optional().describe("Start date"),
|
||||
endDate: z.coerce.date().optional().describe("End date"),
|
||||
});
|
||||
|
||||
export type TGetFilter = z.infer<typeof ZGetFilter>;
|
||||
|
||||
377
apps/web/modules/auth/signup/components/signup-form.test.tsx
Normal file
377
apps/web/modules/auth/signup/components/signup-form.test.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmailTokenAction } from "../../../auth/actions";
|
||||
import { SignupForm } from "./signup-form";
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
}));
|
||||
|
||||
// Set up a push mock for useRouter
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
useSearchParams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-turnstile", () => ({
|
||||
useTurnstile: () => ({
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
default: (props: any) => (
|
||||
<div
|
||||
data-testid="turnstile"
|
||||
onClick={() => {
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess("test-turnstile-token");
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/signup/actions", () => ({
|
||||
createUserAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../auth/actions", () => ({
|
||||
createEmailTokenAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
|
||||
vi.mock("@/modules/ee/sso/components/sso-options", () => ({
|
||||
SSOOptions: () => <div data-testid="sso-options">SSOOptions</div>,
|
||||
}));
|
||||
vi.mock("@/modules/auth/signup/components/terms-privacy-links", () => ({
|
||||
TermsPrivacyLinks: () => <div data-testid="terms-privacy-links">TermsPrivacyLinks</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: (props: any) => <button {...props}>{props.children}</button>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: (props: any) => <input {...props} />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/password-input", () => ({
|
||||
PasswordInput: (props: any) => <input type="password" {...props} />,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
webAppUrl: "http://localhost",
|
||||
privacyUrl: "http://localhost/privacy",
|
||||
termsUrl: "http://localhost/terms",
|
||||
emailAuthEnabled: true,
|
||||
googleOAuthEnabled: false,
|
||||
githubOAuthEnabled: false,
|
||||
azureOAuthEnabled: false,
|
||||
oidcOAuthEnabled: false,
|
||||
userLocale: "en-US",
|
||||
emailVerificationDisabled: false,
|
||||
isSsoEnabled: false,
|
||||
samlSsoEnabled: false,
|
||||
isTurnstileConfigured: false,
|
||||
samlTenant: "",
|
||||
samlProduct: "",
|
||||
defaultOrganizationId: "org1",
|
||||
defaultOrganizationRole: "member",
|
||||
turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false
|
||||
} as const;
|
||||
|
||||
describe("SignupForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("toggles the signup form on button click", () => {
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Initially, the signup form is hidden.
|
||||
try {
|
||||
screen.getByTestId("signup-name");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
// Click the button to reveal the signup form.
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Now the input fields should appear.
|
||||
expect(screen.getByTestId("signup-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-email")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-password")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the form successfully", async () => {
|
||||
// Set up mocks for the API actions.
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Click the button to reveal the signup form.
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
const nameInput = screen.getByTestId("signup-name");
|
||||
const emailInput = screen.getByTestId("signup-email");
|
||||
const passwordInput = screen.getByTestId("signup-password");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "Test User" } });
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "Password123" } });
|
||||
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
// Since email verification is enabled (emailVerificationDisabled is false),
|
||||
// router.push should be called with the verification URL.
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
|
||||
});
|
||||
|
||||
it("submits the form successfully when turnstile is configured", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Simulate receiving a turnstile token by clicking the Turnstile element.
|
||||
const turnstileElement = screen.getByTestId("turnstile");
|
||||
fireEvent.click(turnstileElement);
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success");
|
||||
});
|
||||
|
||||
it("submits the form successfully when turnstile is configured, but createEmailTokenAction don't return data", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue(undefined);
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValue("error");
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Simulate receiving a turnstile token by clicking the Turnstile element.
|
||||
const turnstileElement = screen.getByTestId("turnstile");
|
||||
fireEvent.click(turnstileElement);
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
|
||||
// Since Turnstile is configured, but no token is received, an error message should be shown.
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error message if turnstile is configured, but no token is received", async () => {
|
||||
// Override props to enable Turnstile
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isTurnstileConfigured: true,
|
||||
turnstileSiteKey: "dummy",
|
||||
emailVerificationDisabled: true,
|
||||
};
|
||||
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
|
||||
render(<SignupForm {...props} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
// Since Turnstile is configured, but no token is received, an error message should be shown.
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("auth.signup.please_verify_captcha");
|
||||
});
|
||||
});
|
||||
|
||||
it("Invite token is in the search params", async () => {
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue({ data: true } as any);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("inviteToken=token123") as any);
|
||||
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
// Check that the invite token is passed to the createUserAction
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalledWith({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "Password123",
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "token123",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import { FormProvider, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import Turnstile, { useTurnstile } from "react-turnstile";
|
||||
import { z } from "zod";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
import { createEmailTokenAction } from "../../../auth/actions";
|
||||
@@ -31,8 +30,6 @@ const ZSignupInput = z.object({
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
const turnstileSiteKey = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||
|
||||
type TSignupInput = z.infer<typeof ZSignupInput>;
|
||||
|
||||
interface SignupFormProps {
|
||||
@@ -55,6 +52,7 @@ interface SignupFormProps {
|
||||
isTurnstileConfigured: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
turnstileSiteKey?: string;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -77,6 +75,7 @@ export const SignupForm = ({
|
||||
isTurnstileConfigured,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
turnstileSiteKey,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -171,10 +170,11 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
data-testid="signup-name"
|
||||
value={field.value}
|
||||
name="name"
|
||||
autoFocus
|
||||
onChange={(name) => field.onChange(name)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="bg-white"
|
||||
/>
|
||||
@@ -192,9 +192,10 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
data-testid="signup-email"
|
||||
value={field.value}
|
||||
name="email"
|
||||
onChange={(email) => field.onChange(email)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="work@email.com"
|
||||
className="bg-white"
|
||||
/>
|
||||
@@ -212,10 +213,11 @@ export const SignupForm = ({
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
data-testid="signup-password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
@@ -248,6 +250,7 @@ export const SignupForm = ({
|
||||
|
||||
{showLogin && (
|
||||
<Button
|
||||
data-testid="signup-submit"
|
||||
type="submit"
|
||||
className="h-10 w-full justify-center"
|
||||
loading={form.formState.isSubmitting}
|
||||
@@ -258,6 +261,7 @@ export const SignupForm = ({
|
||||
|
||||
{!showLogin && (
|
||||
<Button
|
||||
data-testid="signup-show-login"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -50,23 +51,50 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
// Mock environment variables and constants
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
SAML_AUDIENCE: "test-saml-audience",
|
||||
SAML_PATH: "test-saml-path",
|
||||
SAML_DATABASE_URL: "test-saml-database-url",
|
||||
TERMS_URL: "test-terms-url",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
PRIVACY_URL: "test-privacy-url",
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
GOOGLE_OAUTH_ENABLED: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
AZURE_OAUTH_ENABLED: true,
|
||||
OIDC_OAUTH_ENABLED: true,
|
||||
OIDC_DISPLAY_NAME: "OpenID",
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
SAML_TENANT: "test-tenant",
|
||||
SAML_PRODUCT: "test-product",
|
||||
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
|
||||
IS_TURNSTILE_CONFIGURED: true,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
DEFAULT_ORGANIZATION_ID: "test-org-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "admin",
|
||||
SAML_TENANT: "test-saml-tenant",
|
||||
SAML_PRODUCT: "test-saml-product",
|
||||
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
}));
|
||||
|
||||
describe("SignupPage", () => {
|
||||
@@ -88,8 +116,11 @@ describe("SignupPage", () => {
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
|
||||
|
||||
const result = await SignupPage({ searchParams: mockSearchParams });
|
||||
@@ -128,7 +159,10 @@ describe("SignupPage", () => {
|
||||
it("calls notFound when invite token is valid but invite is not found", async () => {
|
||||
// Mock the license check functions to return false
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(false);
|
||||
|
||||
await SignupPage({ searchParams: { inviteToken: "test-token" } });
|
||||
@@ -141,8 +175,11 @@ describe("SignupPage", () => {
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
inviteId: "test-invite-id",
|
||||
email: "test@example.com",
|
||||
});
|
||||
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
|
||||
|
||||
const result = await SignupPage({ searchParams: { email: "test@example.com" } });
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SAML_TENANT,
|
||||
SIGNUP_ENABLED,
|
||||
TERMS_URL,
|
||||
TURNSTILE_SITE_KEY,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
@@ -83,6 +84,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal file
291
apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import {
|
||||
TBaseFilters,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import { getSegment } from "../segments";
|
||||
|
||||
// Type for the result of the segment filter to prisma query generation
|
||||
export type SegmentFilterQueryResult = {
|
||||
whereClause: Prisma.ContactWhereInput;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment attribute filter
|
||||
*/
|
||||
const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
// This base query checks if the contact has an attribute with the specified key
|
||||
const baseQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: contactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Handle special operators that don't require a value
|
||||
if (operator === "isSet") {
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (operator === "isNotSet") {
|
||||
return {
|
||||
NOT: baseQuery,
|
||||
};
|
||||
}
|
||||
|
||||
// For all other operators, we need to check the attribute value
|
||||
const valueQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: contactAttributeKey,
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
// Apply the appropriate operator to the attribute value
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "notEquals":
|
||||
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "contains":
|
||||
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "doesNotContain":
|
||||
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
|
||||
break;
|
||||
case "startsWith":
|
||||
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "endsWith":
|
||||
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "greaterThan":
|
||||
valueQuery.attributes.some.value = { gt: String(value) };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
valueQuery.attributes.some.value = { gte: String(value) };
|
||||
break;
|
||||
case "lessThan":
|
||||
valueQuery.attributes.some.value = { lt: String(value) };
|
||||
break;
|
||||
case "lessEqual":
|
||||
valueQuery.attributes.some.value = { lte: String(value) };
|
||||
break;
|
||||
default:
|
||||
valueQuery.attributes.some.value = String(value);
|
||||
}
|
||||
|
||||
return valueQuery;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a person filter
|
||||
*/
|
||||
const buildPersonFilterWhereClause = (filter: TSegmentPersonFilter): Prisma.ContactWhereInput => {
|
||||
const { personIdentifier } = filter.root;
|
||||
|
||||
if (personIdentifier === "userId") {
|
||||
const personFilter: TSegmentAttributeFilter = {
|
||||
...filter,
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: personIdentifier,
|
||||
},
|
||||
};
|
||||
return buildAttributeFilterWhereClause(personFilter);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a device filter
|
||||
*/
|
||||
const buildDeviceFilterWhereClause = (filter: TSegmentDeviceFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { type } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
const baseQuery = {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: type,
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
if (operator === "equals") {
|
||||
baseQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
|
||||
} else if (operator === "notEquals") {
|
||||
baseQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment filter
|
||||
*/
|
||||
const buildSegmentFilterWhereClause = async (
|
||||
filter: TSegmentSegmentFilter,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root } = filter;
|
||||
const { segmentId } = root;
|
||||
|
||||
if (segmentPath.has(segmentId)) {
|
||||
logger.error(
|
||||
{ segmentId, path: Array.from(segmentPath) },
|
||||
"Circular reference detected in segment filter"
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const segment = await getSegment(segmentId);
|
||||
|
||||
if (!segment) {
|
||||
logger.error({ segmentId }, "Segment not found");
|
||||
return {};
|
||||
}
|
||||
|
||||
const newPath = new Set(segmentPath);
|
||||
newPath.add(segmentId);
|
||||
|
||||
return processFilters(segment.filters, newPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes a segment filter or group and returns a Prisma where clause
|
||||
*/
|
||||
const processSingleFilter = async (
|
||||
filter: TSegmentFilter,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root } = filter;
|
||||
|
||||
switch (root.type) {
|
||||
case "attribute":
|
||||
return buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
|
||||
case "person":
|
||||
return buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
|
||||
case "device":
|
||||
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter);
|
||||
case "segment":
|
||||
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes filters and returns a combined Prisma where clause
|
||||
*/
|
||||
const processFilters = async (
|
||||
filters: TBaseFilters,
|
||||
segmentPath: Set<string>
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
if (filters.length === 0) return {};
|
||||
|
||||
const query: { AND: Prisma.ContactWhereInput[]; OR: Prisma.ContactWhereInput[] } = {
|
||||
AND: [],
|
||||
OR: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
const { resource, connector } = filters[i];
|
||||
let whereClause: Prisma.ContactWhereInput;
|
||||
|
||||
// Process the resource based on its type
|
||||
if (isResourceFilter(resource)) {
|
||||
// If it's a single filter, process it directly
|
||||
whereClause = await processSingleFilter(resource, segmentPath);
|
||||
} else {
|
||||
// If it's a group of filters, process it recursively
|
||||
whereClause = await processFilters(resource, segmentPath);
|
||||
}
|
||||
|
||||
if (Object.keys(whereClause).length === 0) continue;
|
||||
if (filters.length === 1) query.AND = [whereClause];
|
||||
else {
|
||||
if (i === 0) {
|
||||
if (filters[1].connector === "and") query.AND.push(whereClause);
|
||||
else query.OR.push(whereClause);
|
||||
} else {
|
||||
if (connector === "and") query.AND.push(whereClause);
|
||||
else query.OR.push(whereClause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(query.AND.length > 0 ? { AND: query.AND } : {}),
|
||||
...(query.OR.length > 0 ? { OR: query.OR } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms a segment filter into a Prisma query for contacts
|
||||
*/
|
||||
export const segmentFilterToPrismaQuery = reactCache(
|
||||
async (segmentId: string, filters: TBaseFilters, environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<SegmentFilterQueryResult, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const baseWhereClause = {
|
||||
environmentId,
|
||||
};
|
||||
|
||||
// Initialize an empty stack for tracking the current evaluation path
|
||||
const segmentPath = new Set<string>([segmentId]);
|
||||
const filtersWhereClause = await processFilters(filters, segmentPath);
|
||||
|
||||
const whereClause = {
|
||||
AND: [baseWhereClause, filtersWhereClause],
|
||||
};
|
||||
|
||||
return ok({ whereClause });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, segmentId, environmentId },
|
||||
"Error transforming segment filter to Prisma query"
|
||||
);
|
||||
return err({
|
||||
type: "bad_request",
|
||||
message: "Failed to convert segment filters to Prisma query",
|
||||
details: [{ field: "segment", issue: "Invalid segment filters" }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`segmentFilterToPrismaQuery-${segmentId}-${environmentId}-${JSON.stringify(filters)}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byEnvironmentId(environmentId), segmentCache.tag.byId(segmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import packageJson from "@/package.json";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getProjects } from "@formbricks/lib/project/service";
|
||||
import { DeleteProject } from "./components/delete-project";
|
||||
import { EditProjectNameForm } from "./components/edit-project-name-form";
|
||||
@@ -51,7 +51,7 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
|
||||
</SettingsCard>
|
||||
<div>
|
||||
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
|
||||
{!IS_FORMBRICKS_CLOUD && (
|
||||
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
|
||||
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
|
||||
)}
|
||||
</div>
|
||||
|
||||
97
apps/web/modules/setup/(fresh-instance)/signup/page.test.tsx
Normal file
97
apps/web/modules/setup/(fresh-instance)/signup/page.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { SignupPage } from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
FB_LOGO_URL: "mock-fb-logo-url",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "smtp-user",
|
||||
SAML_AUDIENCE: "test-saml-audience",
|
||||
SAML_PATH: "test-saml-path",
|
||||
SAML_DATABASE_URL: "test-saml-database-url",
|
||||
TERMS_URL: "test-terms-url",
|
||||
SIGNUP_ENABLED: true,
|
||||
PRIVACY_URL: "test-privacy-url",
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
GOOGLE_OAUTH_ENABLED: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
AZURE_OAUTH_ENABLED: true,
|
||||
OIDC_OAUTH_ENABLED: true,
|
||||
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
|
||||
IS_TURNSTILE_CONFIGURED: true,
|
||||
SAML_TENANT: "test-saml-tenant",
|
||||
SAML_PRODUCT: "test-saml-product",
|
||||
TURNSTILE_SITE_KEY: "test-turnstile-site-key",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getisSsoEnabled: vi.fn(),
|
||||
getIsSamlSsoEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the SignupForm component to simplify our test assertions
|
||||
vi.mock("@/modules/auth/signup/components/signup-form", () => ({
|
||||
SignupForm: (props) => (
|
||||
<div data-testid="signup-form" data-turnstile-key={props.turnstileSiteKey}>
|
||||
SignupForm
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("SignupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(getTranslate).mockResolvedValue((key) => key);
|
||||
});
|
||||
|
||||
it("renders the signup page correctly", async () => {
|
||||
const page = await SignupPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByTestId("signup-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("signup-form")).toHaveAttribute(
|
||||
"data-turnstile-key",
|
||||
"test-turnstile-site-key"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
TERMS_URL,
|
||||
TURNSTILE_SITE_KEY,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
@@ -59,6 +60,7 @@ export const SignupPage = async () => {
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -135,7 +135,7 @@ const nextConfig = {
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value:
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -149,7 +149,7 @@ const nextConfig = {
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value:
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Cache-Control",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
const MOCK_PASSWORD = "Mock_password_for_testing_0nly";
|
||||
|
||||
export const mockUsers = {
|
||||
signup: [
|
||||
{
|
||||
name: "SignUp Flow User 1",
|
||||
email: "signup1@formbricks.com",
|
||||
password: "eN791hZ7wNr9IAscf@",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
onboarding: [
|
||||
{
|
||||
name: "Onboarding User 1",
|
||||
email: "onboarding1@formbricks.com",
|
||||
password: "iHalLonErFGK$X901R0",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Onboarding User 2",
|
||||
email: "onboarding2@formbricks.com",
|
||||
password: "231Xh7D&dM8u75EjIYV",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Onboarding User 3",
|
||||
email: "onboarding3@formbricks.com",
|
||||
password: "231Xh7D&dM8u75EjIYV",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
survey: [
|
||||
{
|
||||
name: "Survey User 1",
|
||||
email: "survey1@formbricks.com",
|
||||
password: "Y1I*EpURUSb32j5XijP",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Survey User 2",
|
||||
email: "survey2@formbricks.com",
|
||||
password: "G73*Gjif22F4JKM1pA",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Survey User 3",
|
||||
email: "survey3@formbricks.com",
|
||||
password: "Gj2DGji27D&M8u53V",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: "Survey User 4",
|
||||
email: "survey4@formbricks.com",
|
||||
password: "UU3efj8vJa&M8u5M1",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
js: [
|
||||
{
|
||||
name: "JS User 1",
|
||||
email: "js1@formbricks.com",
|
||||
password: "XpP%X9UU3efj8vJa",
|
||||
password: MOCK_PASSWORD,
|
||||
},
|
||||
],
|
||||
action: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// vitest.config.ts
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { PluginOption, loadEnv } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
@@ -19,14 +19,15 @@ export default defineConfig({
|
||||
"modules/api/v2/**/*.ts",
|
||||
"modules/api/v2/**/*.tsx",
|
||||
"modules/auth/lib/**/*.ts",
|
||||
"modules/signup/lib/**/*.ts",
|
||||
"modules/auth/signup/lib/**/*.ts",
|
||||
"modules/auth/signup/**/*.tsx",
|
||||
"modules/ee/whitelabel/email-customization/components/*.tsx",
|
||||
"modules/ee/role-management/components/*.tsx",
|
||||
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
|
||||
"modules/email/components/email-template.tsx",
|
||||
"modules/email/emails/survey/follow-up.tsx",
|
||||
"modules/ui/components/post-hog-client/*.tsx",
|
||||
"modules/ee/role-management/components/*.tsx",
|
||||
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
|
||||
"modules/ui/components/alert/*.tsx",
|
||||
"app/(app)/environments/**/layout.tsx",
|
||||
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
|
||||
@@ -52,6 +53,7 @@ export default defineConfig({
|
||||
"modules/survey/lib/client-utils.ts",
|
||||
"modules/survey/list/components/survey-card.tsx",
|
||||
"modules/survey/list/components/survey-dropdown-menu.tsx",
|
||||
"modules/ee/contacts/segments/lib/**/*.ts",
|
||||
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
|
||||
"modules/ee/sso/components/**/*.tsx",
|
||||
],
|
||||
|
||||
@@ -98,7 +98,7 @@ x-environment: &environment
|
||||
############################################# OPTIONAL (OAUTH CONFIGURATION) #############################################
|
||||
|
||||
# Set the below from Cloudflare Turnstile if you want to enable turnstile in signups
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY:
|
||||
# TURNSTILE_SITE_KEY:
|
||||
# TURNSTILE_SECRET_KEY:
|
||||
|
||||
# Set the below from GitHub if you want to enable GitHub OAuth
|
||||
|
||||
@@ -21,6 +21,8 @@ tags:
|
||||
description: Operations for managing contact attributes keys.
|
||||
- name: Management API > Surveys
|
||||
description: Operations for managing surveys.
|
||||
- name: Management API > Surveys > Contact Links
|
||||
description: Operations for generating personalized survey links for contacts.
|
||||
- name: Management API > Webhooks
|
||||
description: Operations for managing webhooks.
|
||||
- name: Organizations API > Teams
|
||||
@@ -629,41 +631,53 @@ paths:
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: &a6
|
||||
- createdAt
|
||||
- updatedAt
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: &a7
|
||||
- asc
|
||||
- desc
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: surveyId
|
||||
schema:
|
||||
@@ -2233,6 +2247,106 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/survey"
|
||||
/surveys/{surveyId}/contact-links/segments/{segmentId}:
|
||||
get:
|
||||
operationId: getContactLinksBySegment
|
||||
summary: Get survey links for contacts in a segment
|
||||
description: Generates personalized survey links for contacts in a segment.
|
||||
tags:
|
||||
- Management API > Surveys > Contact Links
|
||||
parameters:
|
||||
- in: path
|
||||
name: surveyId
|
||||
description: The ID of the survey
|
||||
schema:
|
||||
type: string
|
||||
description: The ID of the survey
|
||||
required: true
|
||||
- in: path
|
||||
name: segmentId
|
||||
description: The ID of the segment
|
||||
schema:
|
||||
type: string
|
||||
description: The ID of the segment
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: expirationDays
|
||||
description: Number of days until the generated JWT expires. If not provided,
|
||||
there is no expiration.
|
||||
schema:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
default: null
|
||||
description: Number of days until the generated JWT expires. If not provided,
|
||||
there is no expiration.
|
||||
- in: query
|
||||
name: attributeKeys
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of contact attribute keys to include in the
|
||||
response. You can have max 20 keys. If not provided, no attributes
|
||||
will be included.
|
||||
responses:
|
||||
"200":
|
||||
description: Contact links generated successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
contactId:
|
||||
type: string
|
||||
description: The ID of the contact
|
||||
surveyUrl:
|
||||
type: string
|
||||
format: uri
|
||||
description: Personalized survey link
|
||||
expiresAt:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
description: The date and time the link expires, null if no expiration
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: The attributes of the contact
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: number
|
||||
limit:
|
||||
type: number
|
||||
offset:
|
||||
type: number
|
||||
/webhooks:
|
||||
get:
|
||||
operationId: getWebhooks
|
||||
@@ -2243,37 +2357,49 @@ paths:
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: surveyIds
|
||||
schema:
|
||||
@@ -2680,37 +2806,49 @@ paths:
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
responses:
|
||||
"200":
|
||||
description: Teams retrieved successfully.
|
||||
@@ -2972,37 +3110,49 @@ paths:
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: teamId
|
||||
schema:
|
||||
@@ -3258,37 +3408,49 @@ paths:
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of items to return
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
maximum: 250
|
||||
default: 50
|
||||
description: Number of items to return
|
||||
- in: query
|
||||
name: skip
|
||||
description: Number of items to skip
|
||||
schema:
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of items to skip
|
||||
- in: query
|
||||
name: sortBy
|
||||
description: Sort by field
|
||||
schema:
|
||||
type: string
|
||||
enum: *a6
|
||||
default: createdAt
|
||||
description: Sort by field
|
||||
- in: query
|
||||
name: order
|
||||
description: Sort order
|
||||
schema:
|
||||
type: string
|
||||
enum: *a7
|
||||
default: desc
|
||||
description: Sort order
|
||||
- in: query
|
||||
name: startDate
|
||||
description: Start date
|
||||
schema:
|
||||
type: string
|
||||
description: Start date
|
||||
- in: query
|
||||
name: endDate
|
||||
description: End date
|
||||
schema:
|
||||
type: string
|
||||
description: End date
|
||||
- in: query
|
||||
name: id
|
||||
schema:
|
||||
|
||||
@@ -14,7 +14,13 @@ import Network
|
||||
static internal var service = FormbricksService()
|
||||
|
||||
// make this class not instantiatable outside of the SDK
|
||||
internal override init() {}
|
||||
internal override init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the Formbricks class.
|
||||
All methods are static and the class serves as a namespace for the SDK,
|
||||
so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
Initializes the Formbricks SDK with the given config ``FormbricksConfig``.
|
||||
|
||||
@@ -22,7 +22,7 @@ struct AnyCodable: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: _AnyEncodable, _AnyDecodable {}
|
||||
extension AnyCodable: AnyEncodableProtocol, AnyDecodableProtocol {}
|
||||
|
||||
extension AnyCodable: Equatable {
|
||||
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
|
||||
@@ -88,12 +88,10 @@ extension AnyCodable: CustomStringConvertible {
|
||||
|
||||
extension AnyCodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch value {
|
||||
case let value as CustomDebugStringConvertible:
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyCodable(\(value.debugDescription))"
|
||||
default:
|
||||
return "AnyCodable(\(description))"
|
||||
}
|
||||
return "AnyCodable(\(description))"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@ struct AnyDecodable: Decodable {
|
||||
}
|
||||
|
||||
@usableFromInline
|
||||
protocol _AnyDecodable {
|
||||
protocol AnyDecodableProtocol {
|
||||
var value: Any { get }
|
||||
init<T>(_ value: T?)
|
||||
}
|
||||
|
||||
extension AnyDecodable: _AnyDecodable {}
|
||||
extension AnyDecodable: AnyDecodableProtocol {}
|
||||
|
||||
extension _AnyDecodable {
|
||||
extension AnyDecodableProtocol {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
@@ -139,10 +139,9 @@ extension AnyDecodable: CustomStringConvertible {
|
||||
|
||||
extension AnyDecodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch value {
|
||||
case let value as CustomDebugStringConvertible:
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyDecodable(\(value.debugDescription))"
|
||||
default:
|
||||
} else {
|
||||
return "AnyDecodable(\(description))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +40,16 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
|
||||
@usableFromInline
|
||||
protocol _AnyEncodable {
|
||||
protocol AnyEncodableProtocol {
|
||||
var value: Any { get }
|
||||
init<T>(_ value: T?)
|
||||
}
|
||||
|
||||
extension AnyEncodable: _AnyEncodable {}
|
||||
extension AnyEncodable: AnyEncodableProtocol {}
|
||||
|
||||
// MARK: - Encodable
|
||||
|
||||
extension _AnyEncodable {
|
||||
extension AnyEncodableProtocol {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
@@ -199,10 +199,9 @@ extension AnyEncodable: CustomStringConvertible {
|
||||
|
||||
extension AnyEncodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch value {
|
||||
case let value as CustomDebugStringConvertible:
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyEncodable(\(value.debugDescription))"
|
||||
default:
|
||||
} else {
|
||||
return "AnyEncodable(\(description))"
|
||||
}
|
||||
}
|
||||
@@ -217,7 +216,7 @@ extension AnyEncodable: ExpressibleByStringInterpolation {}
|
||||
extension AnyEncodable: ExpressibleByArrayLiteral {}
|
||||
extension AnyEncodable: ExpressibleByDictionaryLiteral {}
|
||||
|
||||
extension _AnyEncodable {
|
||||
extension AnyEncodableProtocol {
|
||||
public init(nilLiteral _: ()) {
|
||||
self.init(nil as Any?)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import SwiftUI
|
||||
/// Presents a survey webview to the window's root
|
||||
final class PresentSurveyManager {
|
||||
static let shared = PresentSurveyManager()
|
||||
private init() { }
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the PresentSurveyManager class.
|
||||
The class serves as a namespace for the present method, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
/// The view controller that will present the survey window.
|
||||
private weak var viewController: UIViewController?
|
||||
@@ -29,6 +34,4 @@ final class PresentSurveyManager {
|
||||
func dismissView() {
|
||||
viewController?.dismiss(animated: true)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import SwiftUI
|
||||
/// Filtering surveys based on the user's segments, responses, and displays.
|
||||
final class SurveyManager {
|
||||
static let shared = SurveyManager()
|
||||
private init() { }
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the SurveyManager class.
|
||||
The class serves as a namespace for the shared instance, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
private static let environmentResponseObjectKey = "environmentResponseObjectKey"
|
||||
internal var service = FormbricksService()
|
||||
@@ -124,7 +129,7 @@ private extension SurveyManager {
|
||||
if let environmentResponse = environmentResponse {
|
||||
PresentSurveyManager.shared.present(environmentResponse: environmentResponse, id: id)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// Starts a timer to refresh the environment state after the given timeout (`expiresAt`).
|
||||
@@ -200,7 +205,9 @@ private extension SurveyManager {
|
||||
|
||||
case .displaySome:
|
||||
if let limit = survey.displayLimit {
|
||||
if responses.contains(where: { $0 == survey.id }) { return false }
|
||||
if responses.contains(where: { $0 == survey.id }) {
|
||||
return false
|
||||
}
|
||||
return displays.filter { $0.surveyId == survey.id }.count < limit
|
||||
} else {
|
||||
return true
|
||||
@@ -236,5 +243,5 @@ private extension SurveyManager {
|
||||
return segments.contains(segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import Foundation
|
||||
/// Store and manage user state and sync with the server when needed.
|
||||
final class UserManager {
|
||||
static let shared = UserManager()
|
||||
private init() { }
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the UserManager class.
|
||||
The class serves as a namespace for the user state, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
}
|
||||
|
||||
private static let userIdKey = "userIdKey"
|
||||
private static let contactIdKey = "contactIdKey"
|
||||
|
||||
@@ -54,8 +54,7 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
responseLogMessage.append(urlString)
|
||||
}
|
||||
|
||||
switch httpStatus.responseType {
|
||||
case .success:
|
||||
if httpStatus.responseType == .success {
|
||||
guard let data = data else {
|
||||
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
|
||||
return
|
||||
@@ -73,12 +72,12 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
Formbricks.logger.info(responseLogMessage)
|
||||
|
||||
// We want to save the entire response dictionary for the environment response
|
||||
if var environmentResponse = body as? EnvironmentResponse {
|
||||
if let jsonString = String(data: data, encoding: .utf8) {
|
||||
environmentResponse.responseString = jsonString
|
||||
body = environmentResponse as! Request.Response
|
||||
}
|
||||
if var environmentResponse = body as? EnvironmentResponse,
|
||||
let jsonString = String(data: data, encoding: .utf8) {
|
||||
environmentResponse.responseString = jsonString
|
||||
body = environmentResponse as! Request.Response
|
||||
}
|
||||
|
||||
|
||||
self.completion?(.success(body))
|
||||
}
|
||||
@@ -111,8 +110,7 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
Formbricks.logger.error(responseLogMessage)
|
||||
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
|
||||
}
|
||||
|
||||
default:
|
||||
} else {
|
||||
if let error = error {
|
||||
responseLogMessage.append("\nError: \(error.localizedDescription)")
|
||||
Formbricks.logger.error(responseLogMessage)
|
||||
|
||||
@@ -20,7 +20,7 @@ enum HTTPStatusCode: Int, Error {
|
||||
// MARK: - Informational - 1xx -
|
||||
|
||||
/// - continue: The server has received the request headers and the client should proceed to send the request body.
|
||||
case `continue` = 100
|
||||
case httpContinue = 100
|
||||
|
||||
/// - switchingProtocols: The requester has asked the server to switch protocols and the server has agreed to do so.
|
||||
case switchingProtocols = 101
|
||||
|
||||
@@ -45,7 +45,13 @@ struct SurveyWebView: UIViewRepresentable {
|
||||
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
|
||||
WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
|
||||
records.forEach { record in
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {})
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {
|
||||
/*
|
||||
This completion handler is intentionally empty since we only need to
|
||||
ensure the data is removed. No additional actions are required after
|
||||
the website data has been cleared.
|
||||
*/
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +62,13 @@ extension SurveyWebView {
|
||||
// webView function handles Javascipt alert
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in })
|
||||
alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in
|
||||
/*
|
||||
This closure is intentionally empty since we only need a simple OK button
|
||||
to dismiss the alert. The alert dismissal is handled automatically by the
|
||||
system when the button is tapped.
|
||||
*/
|
||||
})
|
||||
UIApplication.safeKeyWindow?.rootViewController?.presentedViewController?.present(alertController, animated: true)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
@@ -261,7 +261,11 @@ export const BILLING_LIMITS = {
|
||||
} as const;
|
||||
|
||||
export const AI_AZURE_LLM_RESSOURCE_NAME = env.AI_AZURE_LLM_RESSOURCE_NAME;
|
||||
|
||||
export const AI_AZURE_LLM_API_KEY = env.AI_AZURE_LLM_API_KEY;
|
||||
export const AI_AZURE_LLM_DEPLOYMENT_ID = env.AI_AZURE_LLM_DEPLOYMENT_ID;
|
||||
export const AI_AZURE_EMBEDDINGS_RESSOURCE_NAME = env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME;
|
||||
export const AI_AZURE_EMBEDDINGS_API_KEY = env.AI_AZURE_EMBEDDINGS_API_KEY;
|
||||
export const AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID = env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID;
|
||||
export const IS_AI_CONFIGURED = Boolean(
|
||||
env.AI_AZURE_EMBEDDINGS_API_KEY &&
|
||||
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID &&
|
||||
@@ -270,11 +274,6 @@ export const IS_AI_CONFIGURED = Boolean(
|
||||
env.AI_AZURE_LLM_DEPLOYMENT_ID &&
|
||||
env.AI_AZURE_LLM_RESSOURCE_NAME
|
||||
);
|
||||
export const AI_AZURE_LLM_API_KEY = env.AI_AZURE_LLM_API_KEY;
|
||||
export const AI_AZURE_LLM_DEPLOYMENT_ID = env.AI_AZURE_LLM_DEPLOYMENT_ID;
|
||||
export const AI_AZURE_EMBEDDINGS_RESSOURCE_NAME = env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME;
|
||||
export const AI_AZURE_EMBEDDINGS_API_KEY = env.AI_AZURE_EMBEDDINGS_API_KEY;
|
||||
export const AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID = env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID;
|
||||
|
||||
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
|
||||
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
|
||||
@@ -285,8 +284,8 @@ export const POSTHOG_API_HOST = env.POSTHOG_API_HOST;
|
||||
export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST);
|
||||
|
||||
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||
|
||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||
|
||||
export const IS_PRODUCTION = env.NODE_ENV === "production";
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export const env = createEnv({
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
TURNSTILE_SECRET_KEY: z.string().optional(),
|
||||
TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
UPLOADS_DIR: z.string().min(1).optional(),
|
||||
VERCEL_URL: z.string().optional(),
|
||||
WEBAPP_URL: z.string().url().optional(),
|
||||
@@ -128,7 +129,6 @@ export const env = createEnv({
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
},
|
||||
/*
|
||||
* Due to how Next.js bundles environment variables on Edge and Client,
|
||||
@@ -188,7 +188,6 @@ export const env = createEnv({
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
|
||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -226,6 +225,7 @@ export const env = createEnv({
|
||||
SURVEY_URL: process.env.SURVEY_URL,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
TERMS_URL: process.env.TERMS_URL,
|
||||
UPLOADS_DIR: process.env.UPLOADS_DIR,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
|
||||
189
pnpm-lock.yaml
generated
189
pnpm-lock.yaml
generated
@@ -47,7 +47,10 @@ importers:
|
||||
version: link:../../packages/js
|
||||
'@tailwindcss/forms':
|
||||
specifier: 0.5.9
|
||||
version: 0.5.9(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))
|
||||
version: 0.5.9(tailwindcss@4.1.3)
|
||||
'@tailwindcss/postcss':
|
||||
specifier: 4.1.3
|
||||
version: 4.1.3
|
||||
lucide-react:
|
||||
specifier: 0.486.0
|
||||
version: 0.486.0(react@19.0.0)
|
||||
@@ -64,8 +67,8 @@ importers:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
tailwindcss:
|
||||
specifier: 3.4.16
|
||||
version: 3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2))
|
||||
specifier: 4.1.3
|
||||
version: 4.1.3
|
||||
devDependencies:
|
||||
'@formbricks/config-typescript':
|
||||
specifier: workspace:*
|
||||
@@ -347,7 +350,7 @@ importers:
|
||||
version: 0.0.35(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@sentry/nextjs':
|
||||
specifier: 8.52.0
|
||||
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))
|
||||
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1)
|
||||
'@tailwindcss/forms':
|
||||
specifier: 0.5.9
|
||||
version: 0.5.9(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))
|
||||
@@ -413,7 +416,7 @@ importers:
|
||||
version: 0.1.13
|
||||
file-loader:
|
||||
specifier: 6.2.0
|
||||
version: 6.2.0(webpack@5.97.1(esbuild@0.25.2))
|
||||
version: 6.2.0(webpack@5.97.1)
|
||||
framer-motion:
|
||||
specifier: 11.15.0
|
||||
version: 11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@@ -548,7 +551,7 @@ importers:
|
||||
version: 11.1.0
|
||||
webpack:
|
||||
specifier: 5.97.1
|
||||
version: 5.97.1(esbuild@0.25.2)
|
||||
version: 5.97.1
|
||||
xlsx:
|
||||
specifier: 0.18.5
|
||||
version: 0.18.5
|
||||
@@ -5230,6 +5233,82 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20'
|
||||
|
||||
'@tailwindcss/node@4.1.3':
|
||||
resolution: {integrity: sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.1.3':
|
||||
resolution: {integrity: sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.1.3':
|
||||
resolution: {integrity: sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.1.3':
|
||||
resolution: {integrity: sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.1.3':
|
||||
resolution: {integrity: sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3':
|
||||
resolution: {integrity: sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.1.3':
|
||||
resolution: {integrity: sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.3':
|
||||
resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.3':
|
||||
resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.3':
|
||||
resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.1.3':
|
||||
resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.1.3':
|
||||
resolution: {integrity: sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.1.3':
|
||||
resolution: {integrity: sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@tailwindcss/postcss@4.1.3':
|
||||
resolution: {integrity: sha512-6s5nJODm98F++QT49qn8xJKHQRamhYHfMi3X7/ltxiSQ9dyRsaFSfFkfaMsanWzf+TMYQtbk8mt5f6cCVXJwfg==}
|
||||
|
||||
'@tailwindcss/typography@0.5.15':
|
||||
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
|
||||
peerDependencies:
|
||||
@@ -12435,6 +12514,9 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tailwindcss@4.1.3:
|
||||
resolution: {integrity: sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==}
|
||||
|
||||
tapable@2.2.1:
|
||||
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -19136,7 +19218,7 @@ snapshots:
|
||||
|
||||
'@sentry/core@8.52.0': {}
|
||||
|
||||
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))':
|
||||
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.30.0
|
||||
@@ -19147,7 +19229,7 @@ snapshots:
|
||||
'@sentry/opentelemetry': 8.52.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.30.0)
|
||||
'@sentry/react': 8.52.0(react@19.0.0)
|
||||
'@sentry/vercel-edge': 8.52.0
|
||||
'@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(webpack@5.97.1(esbuild@0.25.2))
|
||||
'@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(webpack@5.97.1)
|
||||
chalk: 3.0.0
|
||||
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
resolve: 1.22.8
|
||||
@@ -19223,12 +19305,12 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@sentry/core': 8.52.0
|
||||
|
||||
'@sentry/webpack-plugin@2.22.7(encoding@0.1.13)(webpack@5.97.1(esbuild@0.25.2))':
|
||||
'@sentry/webpack-plugin@2.22.7(encoding@0.1.13)(webpack@5.97.1)':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 2.22.7(encoding@0.1.13)
|
||||
unplugin: 1.0.1
|
||||
uuid: 9.0.1
|
||||
webpack: 5.97.1(esbuild@0.25.2)
|
||||
webpack: 5.97.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -19851,6 +19933,73 @@ snapshots:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2))
|
||||
|
||||
'@tailwindcss/forms@0.5.9(tailwindcss@4.1.3)':
|
||||
dependencies:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/node@4.1.3':
|
||||
dependencies:
|
||||
enhanced-resolve: 5.18.1
|
||||
jiti: 2.4.2
|
||||
lightningcss: 1.29.2
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.1.3':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.1.3':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.1.3
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.1.3
|
||||
'@tailwindcss/oxide-darwin-x64': 4.1.3
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.1.3
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.3
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.3
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.1.3
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.1.3
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.1.3
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.3
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.3
|
||||
|
||||
'@tailwindcss/postcss@4.1.3':
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.1.3
|
||||
'@tailwindcss/oxide': 4.1.3
|
||||
postcss: 8.5.3
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))':
|
||||
dependencies:
|
||||
lodash.castarray: 4.4.0
|
||||
@@ -23289,11 +23438,11 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
file-loader@6.2.0(webpack@5.97.1(esbuild@0.25.2)):
|
||||
file-loader@6.2.0(webpack@5.97.1):
|
||||
dependencies:
|
||||
loader-utils: 2.0.4
|
||||
schema-utils: 3.3.0
|
||||
webpack: 5.97.1(esbuild@0.25.2)
|
||||
webpack: 5.97.1
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
@@ -24421,8 +24570,7 @@ snapshots:
|
||||
|
||||
jiti@2.4.1: {}
|
||||
|
||||
jiti@2.4.2:
|
||||
optional: true
|
||||
jiti@2.4.2: {}
|
||||
|
||||
jju@1.4.0: {}
|
||||
|
||||
@@ -24791,7 +24939,6 @@ snapshots:
|
||||
lightningcss-linux-x64-musl: 1.29.2
|
||||
lightningcss-win32-arm64-msvc: 1.29.2
|
||||
lightningcss-win32-x64-msvc: 1.29.2
|
||||
optional: true
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
@@ -28534,6 +28681,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
tailwindcss@4.1.3: {}
|
||||
|
||||
tapable@2.2.1: {}
|
||||
|
||||
tar-fs@2.1.2:
|
||||
@@ -28616,16 +28765,14 @@ snapshots:
|
||||
ansi-escapes: 4.3.2
|
||||
supports-hyperlinks: 2.3.0
|
||||
|
||||
terser-webpack-plugin@5.3.14(esbuild@0.25.2)(webpack@5.97.1(esbuild@0.25.2)):
|
||||
terser-webpack-plugin@5.3.14(webpack@5.97.1):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.0
|
||||
serialize-javascript: 6.0.2
|
||||
terser: 5.39.0
|
||||
webpack: 5.97.1(esbuild@0.25.2)
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.2
|
||||
webpack: 5.97.1
|
||||
|
||||
terser@5.37.0:
|
||||
dependencies:
|
||||
@@ -29537,7 +29684,7 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webpack@5.97.1(esbuild@0.25.2):
|
||||
webpack@5.97.1:
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.7
|
||||
@@ -29559,7 +29706,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 3.3.0
|
||||
tapable: 2.2.1
|
||||
terser-webpack-plugin: 5.3.14(esbuild@0.25.2)(webpack@5.97.1(esbuild@0.25.2))
|
||||
terser-webpack-plugin: 5.3.14(webpack@5.97.1)
|
||||
watchpack: 2.4.2
|
||||
webpack-sources: 3.2.3
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user