Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes
256a0ec81a attach more telemetry to license check 2025-11-18 21:09:33 +01:00
Johannes
58ab40ab8e chore: remove unused handleBillingLimitsCheck function and utils file
- Delete apps/web/app/api/lib/utils.ts as it only contained a no-op function
- Remove handleBillingLimitsCheck calls from all response creation endpoints
- Function was a placeholder with no actual implementation
2025-11-18 14:51:04 +01:00
94 changed files with 1332 additions and 1598 deletions

View File

@@ -89,7 +89,7 @@ jobs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
@@ -101,7 +101,7 @@ jobs:
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
needs:
- check-latest-release
- docker-build-community
@@ -154,4 +154,4 @@ jobs:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}

View File

@@ -124,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g prisma@6
RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh

View File

@@ -1,8 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
return (
<div className="flex-1 bg-slate-50">
<PosthogIdentify
session={session}
user={user}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient />
{children}
</div>

View File

@@ -8,7 +8,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
@@ -25,11 +25,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<EnvironmentIdBaseLayout>
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>

View File

@@ -1,61 +0,0 @@
"use client";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface PosthogIdentifyProps {
session: Session;
user: TUser;
environmentId?: string;
organizationId?: string;
organizationName?: string;
organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
}
export const PosthogIdentify = ({
session,
user,
environmentId,
organizationId,
organizationName,
organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
useEffect(() => {
if (isPosthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, {
name: user.name,
email: user.email,
});
if (environmentId) {
posthog.group("environment", environmentId, { name: environmentId });
}
if (organizationId) {
posthog.group("organization", organizationId, {
name: organizationName,
plan: organizationBilling?.plan,
responseLimit: organizationBilling?.limits.monthly.responses,
miuLimit: organizationBilling?.limits.monthly.miu,
});
}
}
}, [
posthog,
session.user,
environmentId,
organizationId,
organizationName,
organizationBilling,
user.name,
user.email,
isPosthogEnabled,
]);
return null;
};

View File

@@ -24,11 +24,7 @@ const EnvLayout = async (props: {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentIdBaseLayout>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}

View File

@@ -26,7 +26,6 @@ interface ResponsePageProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
initialResponses?: TResponseWithQuotas[];
}
export const ResponsePage = ({
@@ -40,12 +39,11 @@ export const ResponsePage = ({
isReadOnly,
isQuotasAllowed,
quotas,
initialResponses = [],
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
@@ -58,7 +56,6 @@ export const ResponsePage = ({
const searchParams = useSearchParams();
const fetchNextPage = useCallback(async () => {
if (page === null) return;
const newPage = page + 1;
let newResponses: TResponseWithQuotas[] = [];
@@ -96,22 +93,10 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
// Only fetch if filters are applied (not on initial mount with no filters)
const hasFilters =
selectedFilter?.responseStatus !== "all" ||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
(dateRange.from && dateRange.to);
useEffect(() => {
const fetchFilteredResponses = async () => {
const fetchInitialResponses = async () => {
try {
// skip call for initial mount
if (page === null && !hasFilters) {
setPage(1);
return;
}
setPage(1);
setIsFetchingFirstPage(true);
setFetchingFirstPage(true);
let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
@@ -125,16 +110,19 @@ export const ResponsePage = ({
if (responses.length < responsesPerPage) {
setHasMore(false);
} else {
setHasMore(true);
}
setResponses(responses);
} finally {
setIsFetchingFirstPage(false);
setFetchingFirstPage(false);
}
};
fetchFilteredResponses();
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
setHasMore(true);
}, [filters]);
return (
<>

View File

@@ -3,7 +3,7 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
@@ -56,9 +56,6 @@ const Page = async (props) => {
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
return (
<PageContentWrapper>
<PageHeader
@@ -90,7 +87,6 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
initialResponses={initialResponses}
/>
</PageContentWrapper>
);

View File

@@ -4,7 +4,7 @@ import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
@@ -26,8 +26,8 @@ import {
import { Input } from "@/modules/ui/components/input";
type QuestionFilterComboBoxProps = {
filterOptions: (string | TI18nString)[] | undefined;
filterComboBoxOptions: (string | TI18nString)[] | undefined;
filterOptions: string[] | undefined;
filterComboBoxOptions: string[] | undefined;
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
@@ -74,7 +74,7 @@ export const QuestionFilterComboBox = ({
if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => {
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return !filterComboBoxValue?.includes(optionValue);
});
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
@@ -91,15 +91,14 @@ export const QuestionFilterComboBox = ({
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery, defaultLanguageCode]
);
const handleCommandItemSelect = (o: string | TI18nString) => {
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const handleCommandItemSelect = (o: string) => {
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
@@ -201,18 +200,14 @@ export const QuestionFilterComboBox = ({
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions?.map((o, index) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<DropdownMenuItem
key={`${optionValue}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(optionValue)}>
{optionValue}
</DropdownMenuItem>
);
})}
{filterOptions?.map((o, index) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -274,8 +269,7 @@ export const QuestionFilterComboBox = ({
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<CommandItem
key={optionValue}

View File

@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
SelectedFilterValue,
TResponseStatus,
@@ -13,7 +13,6 @@ import {
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
@@ -27,8 +26,8 @@ import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsCombo
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: (string | TI18nString)[];
filterComboBoxOptions: (string | TI18nString)[];
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
};
@@ -70,12 +69,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
if (!option || option.filterOptions.length === 0) return undefined;
const firstOption = option.filterOptions[0];
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
};
useEffect(() => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
@@ -101,18 +94,15 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}, [isOpen, setSelectedOptions, survey]);
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
const matchingFilterOption = selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
);
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
if (filterValue.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter
filterValue.filter[index] = {
questionType: value,
filterType: {
filterComboBoxValue: undefined,
filterValue: defaultFilterValue,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
},
};
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
@@ -121,7 +111,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
filterValue.filter[index].questionType = value;
filterValue.filter[index].filterType = {
filterComboBoxValue: undefined,
filterValue: defaultFilterValue,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
};
setFilterValue({ ...filterValue });
}

View File

@@ -1,12 +1,9 @@
import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => {
@@ -21,20 +18,9 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<Suspense>
<PostHogPageview
posthogEnabled={IS_POSTHOG_CONFIGURED}
postHogApiHost={POSTHOG_API_HOST}
postHogApiKey={POSTHOG_API_KEY}
/>
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
</PHProvider>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
);
};

View File

@@ -1,34 +0,0 @@
import { Organization } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
export const handleBillingLimitsCheck = async (
environmentId: string,
organizationId: string,
organizationBilling: Organization["billing"]
): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD) return;
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
const responsesLimit = organizationBilling.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organizationBilling.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
};

View File

@@ -18,10 +18,6 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
@@ -58,19 +54,7 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: { responses: monthlyResponseLimit, miu: null },
},
});
} catch (error) {
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
}
}
// Limit check completed
return isLimitReached;
};
@@ -111,10 +95,7 @@ export const GET = withV1ApiWrapper({
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
await updateEnvironment(environment.id, { appSetupCompleted: true });
}
// check organization subscriptions and response limits

View File

@@ -5,7 +5,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return {
response: responses.successResponse(response, true),
};

View File

@@ -8,16 +8,11 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
// Mock dependencies
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(),
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
}));
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
where: { id: environmentId },
data: { appSetupCompleted: true },
});
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.data).toBeDefined();
});
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: mockOrganization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: mockOrganization.billing.limits.monthly.responses,
},
},
});
});
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
@@ -256,21 +237,6 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should handle error when sending Posthog limit reached event", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("Posthog failed");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
@@ -313,7 +279,6 @@ describe("getEnvironmentState", () => {
// Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should propagate database update errors", async () => {
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
});
test("should propagate PostHog event capture errors", async () => {
const incompleteEnvironmentData = {
...mockEnvironmentStateData,
environment: {
...mockEnvironmentStateData.environment,
appSetupCompleted: false,
},
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
// Should throw error since Promise.all will fail if PostHog event capture fails
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
});
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
const result = await getEnvironmentState(environmentId);

View File

@@ -1,15 +1,10 @@
import "server-only";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getEnvironmentStateData } from "./data";
/**
@@ -33,13 +28,10 @@ export const getEnvironmentState = async (
// Handle app setup completion update if needed
// This is a one-time setup flag that can tolerate TTL-based cache expiration
if (!environment.appSetupCompleted) {
await Promise.all([
prisma.environment.update({
where: { id: environmentId },
data: { appSetupCompleted: true },
}),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
await prisma.environment.update({
where: { id: environmentId },
data: { appSetupCompleted: true },
});
}
// Check monthly response limits for Formbricks Cloud
@@ -50,23 +42,7 @@ export const getEnvironmentState = async (
isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
// Send plan limits event if needed
if (isMonthlyResponsesLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: organization.billing.limits.monthly.responses,
},
},
});
} catch (err) {
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
// Limit check completed
}
// Build the response data

View File

@@ -9,7 +9,6 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
@@ -28,18 +27,10 @@ vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc),
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
@@ -145,26 +136,6 @@ describe("createResponse", () => {
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
@@ -186,20 +157,6 @@ describe("createResponse", () => {
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
});
describe("createResponseWithQuotaEvaluation", () => {

View File

@@ -6,11 +6,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact";
@@ -83,7 +81,6 @@ export const createResponse = async (
tx: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -121,8 +118,6 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -10,7 +10,6 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -172,11 +171,6 @@ export const POST = withV1ApiWrapper({
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {

View File

@@ -8,7 +8,6 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { validateInputs } from "@/lib/utils/validate";
@@ -96,9 +95,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
// Mock dependencies
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
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",
@@ -118,10 +114,8 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -234,10 +228,9 @@ describe("Response Lib Tests", () => {
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
});
test("should check response limit and not send event if limit not reached", async () => {
test("should check response limit if limit not reached", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
@@ -251,32 +244,6 @@ describe("Response Lib Tests", () => {
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
const posthogError = new Error("Posthog error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
// Expecting successful response creation despite PostHog error
const response = await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
expect(response).toEqual(mockResponse); // Should still return the created response
});
});
});

View File

@@ -8,14 +8,12 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact";
@@ -93,7 +91,6 @@ export const createResponse = async (
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -131,8 +128,6 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -3,7 +3,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof ResourceNotFoundError) {

View File

@@ -12,9 +12,7 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";
@@ -49,9 +47,7 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({
@@ -166,9 +162,7 @@ describe("createResponse V2", () => {
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
}));
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,
@@ -183,26 +177,6 @@ describe("createResponse V2", () => {
mockIsFormbricksCloud = true;
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
@@ -225,20 +199,6 @@ describe("createResponse V2", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput, mockTx); // Should not throw
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = {

View File

@@ -6,12 +6,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";
@@ -91,7 +89,6 @@ export const createResponse = async (
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
@@ -129,8 +126,6 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -8,7 +8,6 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -148,11 +147,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
});
}
await capturePosthogEnvironmentEvent(environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {

View File

@@ -213,8 +213,8 @@ describe("surveys", () => {
id: "q8",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "c1", label: { default: "Column 1" } }],
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),

View File

@@ -121,8 +121,8 @@ export const generateQuestionAndFilterOptions = (
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
questionFilterOptions.push({
type: q.type,
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
filterOptions: q.rows.flatMap((row) => Object.values(row)),
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
id: q.id,
});
} else {

View File

@@ -1504,7 +1504,7 @@ const docsFeedback = (t: TFunction): TTemplate => {
buildOpenTextQuestion({
headline: t("templates.docs_feedback_question_2_headline"),
required: false,
inputType: "url",
inputType: "text",
t,
}),
buildOpenTextQuestion({

View File

@@ -1252,7 +1252,7 @@ checksums:
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479

View File

@@ -218,10 +218,6 @@ export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const POSTHOG_API_KEY = env.POSTHOG_API_KEY;
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 TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);

View File

@@ -59,8 +59,6 @@ export const env = createEnv({
? z.string().optional()
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
POSTHOG_API_KEY: z.string().optional(),
PRIVACY_URL: z
.string()
.url()
@@ -103,7 +101,6 @@ export const env = createEnv({
}
)
.optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z
.string()
.url()
@@ -172,8 +169,6 @@ export const env = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
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,
@@ -206,7 +201,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
PUBLIC_URL: process.env.PUBLIC_URL,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,

View File

@@ -17,7 +17,6 @@ import {
} from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "../organization/service";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { getUserProjects } from "../project/service";
import { validateInputs } from "../utils/validate";
@@ -173,10 +172,6 @@ export const createEnvironment = async (
},
});
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
environmentType: environment.type,
});
return environment;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -1,56 +0,0 @@
import { PostHog } from "posthog-node";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { IS_POSTHOG_CONFIGURED, IS_PRODUCTION, POSTHOG_API_HOST, POSTHOG_API_KEY } from "./constants";
const enabled = IS_PRODUCTION && IS_POSTHOG_CONFIGURED;
export const capturePosthogEnvironmentEvent = async (
environmentId: string,
eventName: string,
properties: any = {}
) => {
if (!enabled || typeof POSTHOG_API_HOST !== "string" || typeof POSTHOG_API_KEY !== "string") {
return;
}
try {
const client = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_API_HOST,
});
client.capture({
// workaround with a static string as exaplained in PostHog docs: https://posthog.com/docs/product-analytics/group-analytics
distinctId: "environmentEvents",
event: eventName,
groups: { environment: environmentId },
properties,
});
await client.shutdown();
} catch (error) {
logger.error(error, "error sending posthog event");
}
};
export const sendPlanLimitsReachedEventToPosthogWeekly = async (
environmentId: string,
billing: {
plan: TOrganizationBillingPlan;
limits: TOrganizationBillingPlanLimits;
}
) =>
await cache.withCache(
async () => {
try {
await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", {
...billing,
});
return "success";
} catch (error) {
logger.error(error, "error sending plan limits reached event to posthog weekly");
throw error;
}
},
createCacheKey.custom("analytics", environmentId, `plan_limits_${billing.plan}`),
60 * 60 * 24 * 7 * 1000 // 7 days in milliseconds
);

View File

@@ -13,7 +13,6 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { evaluateLogic } from "@/lib/surveyLogic/utils";
import {
mockActionClass,
@@ -44,11 +43,6 @@ vi.mock("@/lib/organization/service", () => ({
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
}));
// Mock posthogServer
vi.mock("@/lib/posthogServer", () => ({
capturePosthogEnvironmentEvent: vi.fn(),
}));
// Mock actionClass service
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
@@ -646,7 +640,6 @@ describe("Tests for createSurvey", () => {
expect(prisma.survey.create).toHaveBeenCalled();
expect(result.name).toEqual(mockSurveyOutput.name);
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
});
test("creates a private segment for app surveys", async () => {

View File

@@ -13,7 +13,6 @@ import {
} from "@/lib/organization/service";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { validateInputs } from "../utils/validate";
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
@@ -673,11 +672,6 @@ export const createSurvey = async (
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -1,38 +0,0 @@
/* We use this telemetry service to better understand how Formbricks is being used
and how we can improve it. All data including the IP address is collected anonymously
and we cannot trace anything back to you or your customers. If you still want to
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION } from "./constants";
import { env } from "./env";
const crypto = require("crypto");
// We are using the hashed CRON_SECRET as the distinct identifier for the instance for telemetry.
// The hash cannot be traced back to the original value or the instance itself.
// This is to ensure that the telemetry data is anonymous but still unique to the instance.
const getTelemetryId = (): string => {
return crypto.createHash("sha256").update(env.CRON_SECRET).digest("hex");
};
export const captureTelemetry = async (eventName: string, properties = {}) => {
if (env.TELEMETRY_DISABLED !== "1" && IS_PRODUCTION) {
try {
await fetch("https://telemetry.formbricks.com/capture/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret
event: eventName,
properties: {
distinct_id: getTelemetryId(),
...properties,
},
timestamp: new Date().toISOString(),
}),
});
} catch (error) {
logger.error(error, "error sending telemetry");
}
}
};

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Befragten erlauben, die Sprache jederzeit zu wechseln. Benötigt mind. 2 aktive Sprachen.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
"enable_spam_protection": "Spamschutz",
"end_screen_card": "Abschluss-Karte",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
"enable_spam_protection": "Spam protection",
"end_screen_card": "End screen card",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux répondants de changer de langue à tout moment. Nécessite au moins 2 langues actives.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
"enable_spam_protection": "Protection contre le spam",
"end_screen_card": "Carte de fin d'écran",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がいつでも言語を切り替えられるようにします。最低2つのアクティブな言語が必要です。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
"enable_recaptcha_to_protect_your_survey_from_spam": "スパム対策はreCAPTCHA v3を使用してスパム回答をフィルタリングします。",
"enable_spam_protection": "スパム対策",
"end_screen_card": "終了画面カード",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Link bewerken",
"edit_recall": "Bewerken Terugroepen",
"edit_translations": "Bewerk {lang} vertalingen",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Sta respondenten toe om op elk moment van taal te wisselen. Vereist min. 2 actieve talen.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Geef deelnemers de mogelijkheid om op elk moment tijdens de enquête van enquêtetaal te wisselen.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spambeveiliging maakt gebruik van reCAPTCHA v3 om de spamreacties eruit te filteren.",
"enable_spam_protection": "Spambescherming",
"end_screen_card": "Eindschermkaart",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os respondentes alterem o idioma a qualquer momento. Necessita de no mínimo 2 idiomas ativos.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
"end_screen_card": "cartão de tela final",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os inquiridos mudem de idioma a qualquer momento. Necessita de pelo menos 2 idiomas ativos.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
"end_screen_card": "Cartão de ecrã final",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite respondenților să schimbe limba în orice moment. Necesită minimum 2 limbi active.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.",
"enable_spam_protection": "Protecția împotriva spamului",
"end_screen_card": "Ecran final card",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允许受访者在调查过程中随时切换语言。需要至少启用两种语言。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾 邮件 保护 使用 reCAPTCHA v3 来 过滤 掉 垃圾 响应 。",
"enable_spam_protection": "垃圾 邮件 保护",
"end_screen_card": "结束 屏幕 卡片",

View File

@@ -1337,7 +1337,7 @@
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許受訪者隨時切換語言。需要至少啟用兩種語言。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
"enable_spam_protection": "垃圾郵件保護",
"end_screen_card": "結束畫面卡片",

View File

@@ -1,13 +1,10 @@
import "server-only";
import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
import {
getMonthlyOrganizationResponseCount,
@@ -51,8 +48,6 @@ export const createResponse = async (
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<Result<Response, ApiErrorResponseV2>> => {
captureTelemetry("response created");
const {
surveyId,
displayId,
@@ -126,7 +121,6 @@ export const createResponse = async (
if (!billing.ok) {
return err(billing.error as ApiErrorResponseV2);
}
const billingData = billing.data;
const prismaClient = tx ?? prisma;
@@ -140,26 +134,7 @@ export const createResponse = async (
return err(responsesCountResult.error as ApiErrorResponseV2);
}
const responsesCount = responsesCountResult.data;
const responsesLimit = billingData.limits?.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: billingData.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw it
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
// Limit check completed
}
return ok(response);

View File

@@ -12,7 +12,6 @@ import {
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import {
getMonthlyOrganizationResponseCount,
getOrganizationBilling,
@@ -20,10 +19,6 @@ import {
} from "@/modules/api/v2/management/responses/lib/organization";
import { createResponse, getResponses } from "../response";
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationBilling: vi.fn(),
@@ -150,11 +145,8 @@ describe("Response Lib", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockImplementation(() => Promise.resolve(""));
const result = await createResponse(environmentId, responseInput);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
@@ -191,10 +183,6 @@ describe("Response Lib", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(
new Error("Error sending plan limits")
);
const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(true);
if (result.ok) {

View File

@@ -1,7 +1,6 @@
import { WebhookSource } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { captureTelemetry } from "@/lib/telemetry";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { createWebhook, getWebhooks } from "../webhook";
@@ -16,10 +15,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
describe("getWebhooks", () => {
const environmentId = "env1";
const params = {
@@ -86,7 +81,6 @@ describe("createWebhook", () => {
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
const result = await createWebhook(inputWebhook);
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
expect(prisma.webhook.create).toHaveBeenCalled();
expect(result.ok).toBe(true);

View File

@@ -1,7 +1,6 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -47,8 +46,6 @@ export const getWebhooks = async (
};
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
captureTelemetry("webhook_created");
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try {

View File

@@ -2,7 +2,6 @@ import { ProjectTeam } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import {
TGetProjectTeamsFilter,
@@ -44,8 +43,6 @@ export const getProjectTeams = async (
export const createProjectTeam = async (
teamInput: TProjectTeamInput
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
captureTelemetry("project team created");
const { teamId, projectId, permission } = teamInput;
try {

View File

@@ -2,7 +2,6 @@ import "server-only";
import { Team } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
import {
TGetTeamsFilter,
@@ -15,8 +14,6 @@ export const createTeam = async (
teamInput: TTeamInput,
organizationId: string
): Promise<Result<Team, ApiErrorResponseV2>> => {
captureTelemetry("team created");
const { name } = teamInput;
try {

View File

@@ -2,7 +2,6 @@ import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/database/zod/users";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
import {
TGetUsersFilter,
@@ -73,8 +72,6 @@ export const createUser = async (
userInput: TUserInput,
organizationId
): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user created");
const { name, email, role, teams, isActive } = userInput;
try {
@@ -150,8 +147,6 @@ export const updateUser = async (
userInput: TUserInputPatch,
organizationId: string
): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user updated");
const { name, email, role, teams, isActive } = userInput;
let existingTeams: string[] = [];
let newTeams;

View File

@@ -13,7 +13,7 @@ import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -46,21 +46,15 @@ const ZCreateUserAction = z.object({
),
});
async function verifyTurnstileIfConfigured(
turnstileToken: string | undefined,
email: string,
name: string
): Promise<void> {
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
if (!IS_TURNSTILE_CONFIGURED) return;
if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
captureFailedSignup(email, name);
throw new UnknownError("Server configuration error");
}
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
if (!isHuman) {
captureFailedSignup(email, name);
throw new UnknownError("reCAPTCHA verification failed");
}
}
@@ -180,7 +174,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.signup);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken);
const hashedPassword = await hashPassword(parsedInput.password);
const { user, userAlreadyExisted } = await createUserSafely(

View File

@@ -13,7 +13,6 @@ import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
import { captureFailedSignup } from "@/modules/auth/signup/lib/utils";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
@@ -236,7 +235,6 @@ export const SignupForm = ({
onError={() => {
setTurnstileToken(undefined);
toast.error(t("auth.signup.captcha_failed"));
captureFailedSignup(form.getValues("email"), form.getValues("name"));
}}
/>
)}

View File

@@ -1,6 +1,5 @@
import posthog from "posthog-js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { captureFailedSignup, verifyTurnstileToken } from "./utils";
import { verifyTurnstileToken } from "./utils";
beforeEach(() => {
global.fetch = vi.fn();
@@ -62,18 +61,3 @@ describe("verifyTurnstileToken", () => {
expect(result).toBe(false);
});
});
describe("captureFailedSignup", () => {
test("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => {
const captureSpy = vi.spyOn(posthog, "capture");
const email = "test@example.com";
const name = "Test User";
captureFailedSignup(email, name);
expect(captureSpy).toHaveBeenCalledWith("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
});
});

View File

@@ -1,5 +1,3 @@
import posthog from "posthog-js";
export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -29,10 +27,3 @@ export const verifyTurnstileToken = async (secretKey: string, token: string): Pr
clearTimeout(timeoutId);
}
};
export const captureFailedSignup = (email: string, name: string) => {
posthog.capture("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
};

View File

@@ -56,9 +56,6 @@ vi.mock("@/lib/constants", () => ({
ITEMS_PER_PAGE: 2,
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
IS_PRODUCTION: false,
IS_POSTHOG_CONFIGURED: false,
POSTHOG_API_HOST: "test-posthog-host",
POSTHOG_API_KEY: "test-posthog-key",
}));
const environmentId = "cm123456789012345678901237";

View File

@@ -4,7 +4,6 @@ import fetch from "node-fetch";
import { cache as reactCache } from "react";
import { z } from "zod";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { env } from "@/lib/env";
@@ -13,6 +12,7 @@ import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { collectTelemetryData } from "./telemetry";
// Configuration
const CONFIG = {
@@ -246,29 +246,48 @@ const handleInitialFailure = async (currentTime: Date) => {
// API functions
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
// Skip license checks during build time
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
if (process.env.NEXT_PHASE === "phase-production-build") {
return null;
}
let telemetryData;
try {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
// first millisecond of next year => current year is fully included
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
telemetryData = await collectTelemetryData(env.ENTERPRISE_LICENSE_KEY || null);
} catch (telemetryError) {
logger.warn({ error: telemetryError }, "Telemetry collection failed, proceeding with minimal data");
telemetryData = {
licenseKey: env.ENTERPRISE_LICENSE_KEY || null,
usage: null,
};
}
const responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
},
},
});
if (!env.ENTERPRISE_LICENSE_KEY) {
try {
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
await fetch(CONFIG.API.ENDPOINT, {
body: JSON.stringify(telemetryData),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (error) {
logger.debug({ error }, "Failed to send telemetry (no license key)");
}
return null;
}
try {
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
@@ -276,10 +295,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
const res = await fetch(CONFIG.API.ENDPOINT, {
body: JSON.stringify({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
}),
body: JSON.stringify(telemetryData),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
@@ -296,7 +312,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
trackApiError(error);
// Retry on specific status codes
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
return fetchLicenseFromServerInternal(retryCount + 1);
@@ -341,6 +356,10 @@ export const getEnterpriseLicense = reactCache(
validateConfig();
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
fetchLicenseFromServerInternal().catch((error) => {
logger.debug({ error }, "Background telemetry send failed (no license key)");
});
return {
active: false,
features: null,

View File

@@ -0,0 +1,245 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { collectTelemetryData } from "./telemetry";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
organization: { count: vi.fn(), findFirst: vi.fn() },
user: { count: vi.fn(), findFirst: vi.fn() },
team: { count: vi.fn() },
project: { count: vi.fn() },
survey: { count: vi.fn(), findFirst: vi.fn() },
contact: { count: vi.fn() },
segment: { count: vi.fn() },
display: { count: vi.fn() },
response: { count: vi.fn() },
surveyLanguage: { findFirst: vi.fn() },
surveyAttributeFilter: { findFirst: vi.fn() },
apiKey: { findFirst: vi.fn() },
teamUser: { findFirst: vi.fn() },
surveyQuota: { findFirst: vi.fn() },
webhook: { findFirst: vi.fn() },
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_STORAGE_CONFIGURED: true,
IS_RECAPTCHA_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
GOOGLE_OAUTH_ENABLED: true,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
AIRTABLE_CLIENT_ID: "test-airtable-id",
SLACK_CLIENT_ID: "test-slack-id",
SLACK_CLIENT_SECRET: "test-slack-secret",
NOTION_OAUTH_CLIENT_ID: "test-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "test-notion-secret",
GOOGLE_SHEETS_CLIENT_ID: "test-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "test-sheets-secret",
}));
describe("Telemetry Collection", () => {
const mockLicenseKey = "test-license-key-123";
const mockOrganizationId = "org-123";
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: mockOrganizationId,
createdAt: new Date(),
} as any);
});
afterEach(() => {
vi.useRealTimers();
});
describe("collectTelemetryData", () => {
test("should return null usage for cloud instances", async () => {
// Mock IS_FORMBRICKS_CLOUD as true for this test
const actualConstants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...(actualConstants as Record<string, unknown>),
IS_FORMBRICKS_CLOUD: true,
}));
// Re-import to get the new mock
const { collectTelemetryData: collectWithCloud } = await import("./telemetry");
const result = await collectWithCloud(mockLicenseKey);
expect(result.licenseKey).toBe(mockLicenseKey);
expect(result.usage).toBeNull();
// Reset mock
vi.resetModules();
});
test("should collect basic counts successfully", async () => {
vi.mocked(prisma.organization.count).mockResolvedValue(1);
vi.mocked(prisma.user.count).mockResolvedValue(5);
vi.mocked(prisma.team.count).mockResolvedValue(2);
vi.mocked(prisma.project.count).mockResolvedValue(3);
vi.mocked(prisma.survey.count).mockResolvedValue(10);
vi.mocked(prisma.contact.count).mockResolvedValue(100);
vi.mocked(prisma.segment.count).mockResolvedValue(5);
vi.mocked(prisma.display.count).mockResolvedValue(500);
vi.mocked(prisma.response.count).mockResolvedValue(1000);
const result = await collectTelemetryData(mockLicenseKey);
expect(result.usage).toBeTruthy();
if (result.usage) {
expect(result.usage.organizationCount).toBe(1);
expect(result.usage.memberCount).toBe(5);
expect(result.usage.teamCount).toBe(2);
expect(result.usage.projectCount).toBe(3);
expect(result.usage.surveyCount).toBe(10);
expect(result.usage.contactCount).toBe(100);
expect(result.usage.segmentCount).toBe(5);
expect(result.usage.surveyDisplayCount).toBe(500);
expect(result.usage.responseCountAllTime).toBe(1000);
}
});
test("should handle query timeouts gracefully", async () => {
// Simulate slow query that times out (but resolve it eventually)
let resolveOrgCount: (value: number) => void;
const orgCountPromise = new Promise<number>((resolve) => {
resolveOrgCount = resolve;
});
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
// Mock other queries to return quickly
vi.mocked(prisma.user.count).mockResolvedValue(5);
vi.mocked(prisma.team.count).mockResolvedValue(2);
vi.mocked(prisma.project.count).mockResolvedValue(3);
vi.mocked(prisma.survey.count).mockResolvedValue(10);
vi.mocked(prisma.contact.count).mockResolvedValue(100);
vi.mocked(prisma.segment.count).mockResolvedValue(5);
vi.mocked(prisma.display.count).mockResolvedValue(500);
vi.mocked(prisma.response.count).mockResolvedValue(1000);
// Mock batch 2 queries
vi.mocked(prisma.survey.findFirst).mockResolvedValue({ id: "survey-1" } as any);
// Start collection
const resultPromise = collectTelemetryData(mockLicenseKey);
// Advance timers past the 2s query timeout
await vi.advanceTimersByTimeAsync(3000);
// Resolve the slow query after timeout
resolveOrgCount!(1);
const result = await resultPromise;
// Should still return result, but with null values for timed-out queries
expect(result.usage).toBeTruthy();
expect(result.usage?.organizationCount).toBeNull();
// Other queries should still work
expect(result.usage?.memberCount).toBe(5);
}, 15000);
test("should handle database errors gracefully", async () => {
const dbError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.organization.count).mockRejectedValue(dbError);
vi.mocked(prisma.user.count).mockResolvedValue(5);
const result = await collectTelemetryData(mockLicenseKey);
// Should continue despite errors
expect(result.usage).toBeTruthy();
expect(result.usage?.organizationCount).toBeNull();
expect(result.usage?.memberCount).toBe(5);
});
test("should detect feature usage correctly", async () => {
// Mock feature detection queries
vi.mocked(prisma.surveyLanguage.findFirst).mockResolvedValue({ languageId: "en" } as any);
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({
id: "user-2",
twoFactorEnabled: true,
} as any);
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue({ id: "key-1" } as any);
// Mock all count queries to return 0 to avoid complexity
vi.mocked(prisma.organization.count).mockResolvedValue(0);
vi.mocked(prisma.user.count).mockResolvedValue(0);
vi.mocked(prisma.team.count).mockResolvedValue(0);
vi.mocked(prisma.project.count).mockResolvedValue(0);
vi.mocked(prisma.survey.count).mockResolvedValue(0);
vi.mocked(prisma.contact.count).mockResolvedValue(0);
vi.mocked(prisma.segment.count).mockResolvedValue(0);
vi.mocked(prisma.display.count).mockResolvedValue(0);
vi.mocked(prisma.response.count).mockResolvedValue(0);
const result = await collectTelemetryData(mockLicenseKey);
expect(result.usage?.featureUsage).toBeTruthy();
if (result.usage?.featureUsage) {
expect(result.usage.featureUsage.multiLanguageSurveys).toBe(true);
expect(result.usage.featureUsage.twoFA).toBe(true);
expect(result.usage.featureUsage.apiKeys).toBe(true);
expect(result.usage.featureUsage.sso).toBe(true); // From constants
expect(result.usage.featureUsage.fileUpload).toBe(true); // From constants
}
});
test("should generate instance ID when no organization exists", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
const result = await collectTelemetryData(mockLicenseKey);
expect(result.usage).toBeTruthy();
expect(result.usage?.instanceId).toBeTruthy();
expect(typeof result.usage?.instanceId).toBe("string");
});
test("should handle total timeout gracefully", async () => {
let resolveOrgFind: (value: any) => void;
const orgFindPromise = new Promise<any>((resolve) => {
resolveOrgFind = resolve;
});
vi.mocked(prisma.organization.findFirst).mockImplementation(() => orgFindPromise as any);
let resolveOrgCount: (value: number) => void;
const orgCountPromise = new Promise<number>((resolve) => {
resolveOrgCount = resolve;
});
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
// Start collection
const resultPromise = collectTelemetryData(mockLicenseKey);
// Advance timers past the 15s total timeout
await vi.advanceTimersByTimeAsync(16000);
resolveOrgFind!({ id: mockOrganizationId, createdAt: new Date() });
resolveOrgCount!(1);
const result = await resultPromise;
// Should return usage object (may be empty or partial)
expect(result.usage).toBeTruthy();
}, 20000);
});
});

View File

@@ -0,0 +1,630 @@
import "server-only";
import crypto from "node:crypto";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import {
AIRTABLE_CLIENT_ID,
AUDIT_LOG_ENABLED,
AZURE_OAUTH_ENABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
IS_FORMBRICKS_CLOUD,
IS_RECAPTCHA_CONFIGURED,
IS_STORAGE_CONFIGURED,
NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET,
OIDC_OAUTH_ENABLED,
SAML_OAUTH_ENABLED,
SLACK_CLIENT_ID,
SLACK_CLIENT_SECRET,
} from "@/lib/constants";
const CONFIG = {
QUERY_TIMEOUT_MS: 2000,
BATCH_TIMEOUT_MS: 5000,
TOTAL_TIMEOUT_MS: 15000,
} as const;
export type TelemetryUsage = {
instanceId: string;
organizationCount: number | null;
memberCount: number | null;
teamCount: number | null;
projectCount: number | null;
surveyCount: number | null;
activeSurveyCount: number | null;
completedSurveyCount: number | null;
responseCountAllTime: number | null;
responseCountLast30d: number | null;
surveyDisplayCount: number | null;
contactCount: number | null;
segmentCount: number | null;
featureUsage: {
multiLanguageSurveys: boolean | null;
advancedTargeting: boolean | null;
sso: boolean | null;
saml: boolean | null;
twoFA: boolean | null;
apiKeys: boolean | null;
teamRoles: boolean | null;
auditLogs: boolean | null;
whitelabel: boolean | null;
removeBranding: boolean | null;
fileUpload: boolean | null;
spamProtection: boolean | null;
quotas: boolean | null;
};
activeIntegrations: {
airtable: boolean | null;
slack: boolean | null;
notion: boolean | null;
googleSheets: boolean | null;
zapier: boolean | null;
make: boolean | null;
n8n: boolean | null;
webhook: boolean | null;
};
temporal: {
instanceCreatedAt: string | null;
newestSurveyDate: string | null;
};
};
export type TelemetryData = {
licenseKey: string | null;
usage: TelemetryUsage | null;
};
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
return Promise.race([
promise,
new Promise<T | null>((resolve) => {
setTimeout(() => {
logger.warn({ timeoutMs }, "Query timeout exceeded");
resolve(null);
}, timeoutMs);
}),
]);
};
const safeQuery = async <T>(
queryFn: () => Promise<T>,
queryName: string,
batchNumber: number
): Promise<T | null> => {
try {
const result = await withTimeout(queryFn(), CONFIG.QUERY_TIMEOUT_MS);
return result;
} catch (error) {
logger.error(
{
error,
queryName,
batchNumber,
},
`Telemetry query failed: ${queryName}`
);
return null;
}
};
const getInstanceId = async (): Promise<string> => {
try {
const firstOrg = await withTimeout(
prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
}),
CONFIG.QUERY_TIMEOUT_MS
);
if (!firstOrg) {
return crypto.randomUUID();
}
return crypto.createHash("sha256").update(firstOrg.id).digest("hex").substring(0, 32);
} catch (error) {
logger.error({ error }, "Failed to get instance ID, using random UUID");
return crypto.randomUUID();
}
};
const collectBatch1 = async (): Promise<Partial<TelemetryUsage>> => {
const queries = [
{
name: "organizationCount",
fn: () => prisma.organization.count(),
},
{
name: "memberCount",
fn: () => prisma.user.count(),
},
{
name: "teamCount",
fn: () => prisma.team.count(),
},
{
name: "projectCount",
fn: () => prisma.project.count(),
},
{
name: "surveyCount",
fn: () => prisma.survey.count(),
},
{
name: "contactCount",
fn: () => prisma.contact.count(),
},
{
name: "segmentCount",
fn: () => prisma.segment.count(),
},
{
name: "surveyDisplayCount",
fn: () => prisma.display.count(),
},
{
name: "responseCountAllTime",
fn: () => prisma.response.count(),
},
];
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 1)));
const batchResult: Partial<TelemetryUsage> = {};
for (const [index, result] of results.entries()) {
const key = queries[index].name as keyof TelemetryUsage;
if (result.status === "fulfilled" && result.value !== null) {
(batchResult as Record<string, unknown>)[key] = result.value;
} else {
(batchResult as Record<string, unknown>)[key] = null;
}
}
return batchResult;
};
const collectBatch2 = async (): Promise<Partial<TelemetryUsage>> => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const queries = [
{
name: "activeSurveyCount",
fn: () => prisma.survey.count({ where: { status: "inProgress" } }),
},
{
name: "completedSurveyCount",
fn: () => prisma.survey.count({ where: { status: "completed" } }),
},
{
name: "responseCountLast30d",
fn: () =>
prisma.response.count({
where: {
createdAt: {
gte: thirtyDaysAgo,
},
},
}),
},
];
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 2)));
const batchResult: Partial<TelemetryUsage> = {};
for (const [index, result] of results.entries()) {
const key = queries[index].name as keyof TelemetryUsage;
if (result.status === "fulfilled" && result.value !== null) {
(batchResult as Record<string, unknown>)[key] = result.value;
} else {
(batchResult as Record<string, unknown>)[key] = null;
}
}
return batchResult;
};
const collectBatch3 = async (): Promise<Partial<TelemetryUsage>> => {
const queries = [
{
name: "multiLanguageSurveys",
fn: async () => {
const result = await prisma.surveyLanguage.findFirst({ select: { languageId: true } });
return result !== null;
},
},
{
name: "advancedTargeting",
fn: async () => {
const [hasFilters, hasSegments] = await Promise.all([
prisma.surveyAttributeFilter.findFirst({ select: { id: true } }),
prisma.survey.findFirst({ where: { segmentId: { not: null } } }),
]);
return hasFilters !== null || hasSegments !== null;
},
},
{
name: "twoFA",
fn: async () => {
const result = await prisma.user.findFirst({
where: { twoFactorEnabled: true },
select: { id: true },
});
return result !== null;
},
},
{
name: "apiKeys",
fn: async () => {
const result = await prisma.apiKey.findFirst({ select: { id: true } });
return result !== null;
},
},
{
name: "teamRoles",
fn: async () => {
const result = await prisma.teamUser.findFirst({ select: { teamId: true } });
return result !== null;
},
},
{
name: "whitelabel",
fn: async () => {
const organizations = await prisma.organization.findMany({
select: { whitelabel: true },
take: 100,
});
return organizations.some((org) => {
const whitelabel = org.whitelabel as Record<string, unknown> | null;
return whitelabel !== null && typeof whitelabel === "object" && Object.keys(whitelabel).length > 0;
});
},
},
{
name: "removeBranding",
fn: async () => {
const organizations = await prisma.organization.findMany({
select: { billing: true },
take: 100,
});
return organizations.some((org) => {
const billing = org.billing as { plan?: string; removeBranding?: boolean } | null;
return billing?.removeBranding === true;
});
},
},
{
name: "quotas",
fn: async () => {
const result = await prisma.surveyQuota.findFirst({ select: { id: true } });
return result !== null;
},
},
];
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 3)));
const batchResult: Partial<TelemetryUsage> = {
featureUsage: {
multiLanguageSurveys: null,
advancedTargeting: null,
sso: null,
saml: null,
twoFA: null,
apiKeys: null,
teamRoles: null,
auditLogs: null,
whitelabel: null,
removeBranding: null,
fileUpload: null,
spamProtection: null,
quotas: null,
},
};
const featureMap: Record<string, keyof TelemetryUsage["featureUsage"]> = {
multiLanguageSurveys: "multiLanguageSurveys",
advancedTargeting: "advancedTargeting",
twoFA: "twoFA",
apiKeys: "apiKeys",
teamRoles: "teamRoles",
whitelabel: "whitelabel",
removeBranding: "removeBranding",
quotas: "quotas",
};
for (const [index, result] of results.entries()) {
const queryName = queries[index].name;
const featureKey = featureMap[queryName];
if (featureKey && batchResult.featureUsage) {
if (result.status === "fulfilled" && result.value !== null) {
batchResult.featureUsage[featureKey] = result.value;
} else {
batchResult.featureUsage[featureKey] = null;
}
}
}
if (batchResult.featureUsage) {
batchResult.featureUsage.sso =
GOOGLE_OAUTH_ENABLED ||
GITHUB_OAUTH_ENABLED ||
AZURE_OAUTH_ENABLED ||
OIDC_OAUTH_ENABLED ||
SAML_OAUTH_ENABLED;
batchResult.featureUsage.saml = SAML_OAUTH_ENABLED;
batchResult.featureUsage.auditLogs = AUDIT_LOG_ENABLED;
batchResult.featureUsage.fileUpload = IS_STORAGE_CONFIGURED;
batchResult.featureUsage.spamProtection = IS_RECAPTCHA_CONFIGURED;
}
return batchResult;
};
const collectBatch4 = async (): Promise<Partial<TelemetryUsage>> => {
const booleanQueries = [
{
name: "zapier",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "zapier" },
select: { id: true },
});
return result !== null;
},
},
{
name: "make",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "make" },
select: { id: true },
});
return result !== null;
},
},
{
name: "n8n",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "n8n" },
select: { id: true },
});
return result !== null;
},
},
{
name: "webhook",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "user" },
select: { id: true },
});
return result !== null;
},
},
];
const stringQueries = [
{
name: "instanceCreatedAt",
fn: async (): Promise<string | null> => {
const result = await prisma.user.findFirst({
orderBy: { createdAt: "asc" },
select: { createdAt: true },
});
return result?.createdAt.toISOString() ?? null;
},
},
{
name: "newestSurveyDate",
fn: async (): Promise<string | null> => {
const result = await prisma.survey.findFirst({
orderBy: { createdAt: "desc" },
select: { createdAt: true },
});
return result?.createdAt.toISOString() ?? null;
},
},
];
const booleanResults = await Promise.allSettled(
booleanQueries.map((query) => safeQuery(query.fn, query.name, 4))
);
const stringResults = await Promise.allSettled(
stringQueries.map((query) => safeQuery(query.fn, query.name, 4))
);
const batchResult: Partial<TelemetryUsage> = {
activeIntegrations: {
airtable: null,
slack: null,
notion: null,
googleSheets: null,
zapier: null,
make: null,
n8n: null,
webhook: null,
},
temporal: {
instanceCreatedAt: null,
newestSurveyDate: null,
},
};
const integrationMap: Record<string, keyof TelemetryUsage["activeIntegrations"]> = {
zapier: "zapier",
make: "make",
n8n: "n8n",
webhook: "webhook",
};
for (const [index, result] of booleanResults.entries()) {
const queryName = booleanQueries[index].name;
const integrationKey = integrationMap[queryName];
if (integrationKey && batchResult.activeIntegrations) {
if (result.status === "fulfilled" && result.value !== null) {
batchResult.activeIntegrations[integrationKey] = result.value;
} else {
batchResult.activeIntegrations[integrationKey] = null;
}
}
}
for (const [index, result] of stringResults.entries()) {
const queryName = stringQueries[index].name;
if (batchResult.temporal && (queryName === "instanceCreatedAt" || queryName === "newestSurveyDate")) {
if (result.status === "fulfilled" && result.value !== null) {
batchResult.temporal[queryName] = result.value;
}
}
}
if (batchResult.activeIntegrations) {
batchResult.activeIntegrations.airtable = !!AIRTABLE_CLIENT_ID;
batchResult.activeIntegrations.slack = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
batchResult.activeIntegrations.notion = !!(NOTION_OAUTH_CLIENT_ID && NOTION_OAUTH_CLIENT_SECRET);
batchResult.activeIntegrations.googleSheets = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET);
}
return batchResult;
};
export const collectTelemetryData = async (licenseKey: string | null): Promise<TelemetryData> => {
if (IS_FORMBRICKS_CLOUD) {
return {
licenseKey,
usage: null,
};
}
const startTime = Date.now();
try {
const instanceId = await getInstanceId();
const batchPromises = [
Promise.race([
collectBatch1(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 1 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch2(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 2 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch3(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 3 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch4(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 4 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
];
const batchResults = await Promise.race([
Promise.all(batchPromises),
new Promise<Partial<TelemetryUsage>[]>((resolve) => {
setTimeout(() => {
logger.warn("Total telemetry collection timeout");
resolve([{}, {}, {}, {}]);
}, CONFIG.TOTAL_TIMEOUT_MS);
}),
]);
const usage: TelemetryUsage = {
instanceId,
organizationCount: null,
memberCount: null,
teamCount: null,
projectCount: null,
surveyCount: null,
activeSurveyCount: null,
completedSurveyCount: null,
responseCountAllTime: null,
responseCountLast30d: null,
surveyDisplayCount: null,
contactCount: null,
segmentCount: null,
featureUsage: {
multiLanguageSurveys: null,
advancedTargeting: null,
sso: null,
saml: null,
twoFA: null,
apiKeys: null,
teamRoles: null,
auditLogs: null,
whitelabel: null,
removeBranding: null,
fileUpload: null,
spamProtection: null,
quotas: null,
},
activeIntegrations: {
airtable: null,
slack: null,
notion: null,
googleSheets: null,
zapier: null,
make: null,
n8n: null,
webhook: null,
},
temporal: {
instanceCreatedAt: null,
newestSurveyDate: null,
},
};
for (const batchResult of batchResults) {
Object.assign(usage, batchResult);
if (batchResult.featureUsage) {
Object.assign(usage.featureUsage, batchResult.featureUsage);
}
if (batchResult.activeIntegrations) {
Object.assign(usage.activeIntegrations, batchResult.activeIntegrations);
}
if (batchResult.temporal) {
Object.assign(usage.temporal, batchResult.temporal);
}
}
const duration = Date.now() - startTime;
logger.info({ duration, instanceId }, "Telemetry collection completed");
return {
licenseKey,
usage,
};
} catch (error) {
logger.error({ error, duration: Date.now() - startTime }, "Telemetry collection failed completely");
return {
licenseKey,
usage: null,
};
}
};

View File

@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import type { TSurvey, TSurveyLanguage, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { addMultiLanguageLabels, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
@@ -177,8 +177,6 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
const [parent] = useAutoAnimate();
const enabledLanguages = getEnabledLanguages(localSurvey.languages);
return (
<div
className={cn(
@@ -302,7 +300,6 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<AdvancedOptionToggle
customContainerClass="px-0 pt-0"
htmlId="languageSwitch"
disabled={enabledLanguages.length <= 1}
isChecked={!!localSurvey.showLanguageSwitch}
onToggle={handleLanguageSwitchToggle}
title={t("environments.surveys.edit.show_language_switch")}

View File

@@ -9,16 +9,11 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
import { createSurvey, handleTriggerUpdates } from "./survey";
// Mock dependencies
vi.mock("@/lib/posthogServer", () => ({
capturePosthogEnvironmentEvent: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
@@ -121,11 +116,6 @@ describe("survey module", () => {
"user-123",
"org-123"
);
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(
environmentId,
"survey created",
expect.objectContaining({ surveyId: "survey-123" })
);
expect(result).toBeDefined();
expect(result.id).toBe("survey-123");
});

View File

@@ -7,7 +7,6 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
@@ -122,11 +121,6 @@ export const createSurvey = async (
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -30,6 +30,10 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
}
}, [survey, isReadOnly]);
useEffect(() => {
refreshSingleUseId();
}, [refreshSingleUseId]);
return {
singleUseId: isReadOnly ? undefined : singleUseId,
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,

View File

@@ -45,11 +45,11 @@ export const selectSurvey = {
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
code: true,
projectId: true,
alias: true,
},
},
},
@@ -72,15 +72,7 @@ export const selectSurvey = {
},
},
segment: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
title: true,
description: true,
isPrivate: true,
filters: true,
include: {
surveys: {
select: {
id: true,

View File

@@ -1,110 +1,160 @@
"use client";
import { Project } from "@prisma/client";
import { Project, Response } from "@prisma/client";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TProjectStyling } from "@formbricks/types/project";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TResponseData, TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { SurveyLinkUsed } from "@/modules/survey/link/components/survey-link-used";
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
survey: TSurvey;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
styling: TProjectStyling | TSurveyStyling;
publicDomain: string;
responseCount?: number;
languageCode: string;
isEmbed: boolean;
singleUseId?: string;
singleUseResponseId?: string;
contactId?: string;
recaptchaSiteKey?: string;
isSpamProtectionEnabled: boolean;
isPreview: boolean;
verifiedEmail?: string;
IMPRINT_URL?: string;
PRIVACY_URL?: string;
IS_FORMBRICKS_CLOUD: boolean;
}
// Module-level functions to allow SurveyInline to control survey state
let setQuestionId = (_: string) => {};
let setResponseData = (_: TResponseData) => {};
export const SurveyClientWrapper = ({
interface LinkSurveyProps {
survey: TSurvey;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
emailVerificationStatus?: string;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
publicDomain: string;
responseCount?: number;
verifiedEmail?: string;
languageCode: string;
isEmbed: boolean;
IMPRINT_URL?: string;
PRIVACY_URL?: string;
IS_FORMBRICKS_CLOUD: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
recaptchaSiteKey?: string;
isSpamProtectionEnabled?: boolean;
}
export const LinkSurvey = ({
survey,
project,
styling,
emailVerificationStatus,
singleUseId,
singleUseResponse,
publicDomain,
responseCount,
verifiedEmail,
languageCode,
isEmbed,
singleUseId,
singleUseResponseId,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled,
isPreview,
verifiedEmail,
IMPRINT_URL,
PRIVACY_URL,
IS_FORMBRICKS_CLOUD,
}: SurveyClientWrapperProps) => {
locale,
isPreview,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled = false,
}: LinkSurveyProps) => {
const responseId = singleUseResponse?.id;
const searchParams = useSearchParams();
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
const suId = searchParams.get("suId");
const startAt = searchParams.get("startAt");
// Extract survey properties outside useMemo to create stable references
const welcomeCardEnabled = survey.welcomeCard.enabled;
const surveyQuestions = survey.questions;
// Validate startAt parameter against survey questions
const isStartAtValid = useMemo(() => {
if (!startAt) return false;
if (welcomeCardEnabled && startAt === "start") return true;
const isValid = surveyQuestions.some((q) => q.id === startAt);
if (survey.welcomeCard.enabled && startAt === "start") return true;
// Clean up invalid startAt from URL to prevent confusion
if (!isValid && globalThis.window !== undefined) {
const url = new URL(globalThis.location.href);
const isValid = survey.questions.some((question) => question.id === startAt);
// To remove startAt query param from URL if it is not valid:
if (!isValid && typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.delete("startAt");
globalThis.history.replaceState({}, "", url.toString());
window.history.replaceState({}, "", url.toString());
}
return isValid;
}, [welcomeCardEnabled, surveyQuestions, startAt]);
}, [survey, startAt]);
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
const [autoFocus, setAutoFocus] = useState(false);
// Enable autofocus only when not in iframe
useEffect(() => {
if (globalThis.self === globalThis.top) {
setAutoFocus(true);
const [autoFocus, setAutoFocus] = useState(false);
const hasFinishedSingleUseResponse = useMemo(() => {
if (singleUseResponse?.finished) {
return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
}, []);
// Extract hidden fields from URL parameters
const hiddenFieldsRecord = useMemo(() => {
const fieldsRecord: Record<string, string> = {};
for (const field of survey.hiddenFields.fieldIds || []) {
const answer = searchParams.get(field);
if (answer) fieldsRecord[field] = answer;
// Not in an iframe, enable autofocus on input fields.
useEffect(() => {
if (window.self === window.top) {
setAutoFocus(true);
}
return fieldsRecord;
}, [searchParams, JSON.stringify(survey.hiddenFields.fieldIds || [])]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
}, []);
const hiddenFieldsRecord = useMemo<TResponseHiddenFieldValue>(() => {
const fieldsRecord: TResponseHiddenFieldValue = {};
survey.hiddenFields.fieldIds?.forEach((field) => {
const answer = searchParams.get(field);
if (answer) {
fieldsRecord[field] = answer;
}
});
return fieldsRecord;
}, [searchParams, survey.hiddenFields.fieldIds]);
// Include verified email in hidden fields if available
const getVerifiedEmail = useMemo<Record<string, string> | null>(() => {
if (survey.isVerifyEmailEnabled && verifiedEmail) {
return { verifiedEmail: verifiedEmail };
} else {
return null;
}
return null;
}, [survey.isVerifyEmailEnabled, verifiedEmail]);
if (hasFinishedSingleUseResponse) {
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
}
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
if (emailVerificationStatus === "fishy") {
return (
<VerifyEmail
survey={survey}
isErrorComponent={true}
languageCode={languageCode}
styling={project.styling}
locale={locale}
/>
);
}
//emailVerificationStatus === "not-verified"
return (
<VerifyEmail
singleUseId={suId ?? ""}
survey={survey}
languageCode={languageCode}
styling={project.styling}
locale={locale}
/>
);
}
const determineStyling = () => {
// Check if style overwrite is disabled at the project level
if (!project.styling.allowStyleOverwrite) {
return project.styling;
}
// Return survey styling if survey overwrites are enabled, otherwise return project styling
return survey.styling?.overwriteThemeStyling ? survey.styling : project.styling;
};
const handleResetSurvey = () => {
setQuestionId(survey.welcomeCard.enabled ? "start" : survey.questions[0].id);
setResponseData({});
@@ -117,8 +167,8 @@ export const SurveyClientWrapper = ({
isWelcomeCardEnabled={survey.welcomeCard.enabled}
isPreview={isPreview}
surveyType={survey.type}
determineStyling={() => styling}
handleResetSurvey={handleResetSurvey}
determineStyling={determineStyling}
isEmbed={isEmbed}
publicDomain={publicDomain}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
@@ -130,10 +180,11 @@ export const SurveyClientWrapper = ({
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
styling={styling}
styling={determineStyling()}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
shouldResetQuestionId={false}
// eslint-disable-next-line jsx-a11y/no-autofocus -- need it as focus behaviour is different in normal surveys and survey preview
autoFocus={autoFocus}
prefillResponseData={prefillValue}
skipPrefilled={skipPrefilled}
@@ -151,7 +202,7 @@ export const SurveyClientWrapper = ({
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponseId}
singleUseResponseId={responseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}

View File

@@ -3,17 +3,17 @@
import { Project, Response } from "@prisma/client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { validateSurveyPinAction } from "@/modules/survey/link/actions";
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { OTPInput } from "@/modules/ui/components/otp-input";
interface PinScreenProps {
surveyId: string;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
emailVerificationStatus?: string;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
publicDomain: string;
@@ -23,12 +23,11 @@ interface PinScreenProps {
verifiedEmail?: string;
languageCode: string;
isEmbed: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
recaptchaSiteKey?: string;
isSpamProtectionEnabled?: boolean;
responseCount?: number;
styling: TProjectStyling | TSurveyStyling;
}
export const PinScreen = (props: PinScreenProps) => {
@@ -36,6 +35,7 @@ export const PinScreen = (props: PinScreenProps) => {
surveyId,
project,
publicDomain,
emailVerificationStatus,
singleUseId,
singleUseResponse,
IMPRINT_URL,
@@ -44,12 +44,11 @@ export const PinScreen = (props: PinScreenProps) => {
verifiedEmail,
languageCode,
isEmbed,
locale,
isPreview,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled = false,
responseCount,
styling,
} = props;
const [localPinEntry, setLocalPinEntry] = useState<string>("");
@@ -117,24 +116,24 @@ export const PinScreen = (props: PinScreenProps) => {
}
return (
<SurveyClientWrapper
<LinkSurvey
survey={survey}
project={project}
styling={styling}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
publicDomain={publicDomain}
responseCount={responseCount}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponse?.id}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
isPreview={isPreview}
verifiedEmail={verifiedEmail}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
);
};

View File

@@ -1,21 +1,22 @@
"use client";
import { Project } from "@prisma/client";
import { CheckCircle2Icon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurveySingleUse } from "@formbricks/types/surveys/types";
import { getTranslate } from "@/lingodotdev/server";
import footerLogo from "../lib/footerlogo.svg";
interface SurveyCompletedMessageProps {
interface SurveyLinkUsedProps {
singleUseMessage: TSurveySingleUse | null;
project?: Pick<Project, "linkSurveyBranding">;
}
export const SurveyCompletedMessage = async ({ singleUseMessage, project }: SurveyCompletedMessageProps) => {
const t = await getTranslate();
export const SurveyLinkUsed = ({ singleUseMessage, project }: SurveyLinkUsedProps) => {
const { t } = useTranslation();
const defaultHeading = t("s.survey_already_answered_heading");
const defaultSubheading = t("s.survey_already_answered_subheading");
return (
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">

View File

@@ -1,8 +1,6 @@
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
IMPRINT_URL,
IS_FORMBRICKS_CLOUD,
@@ -11,13 +9,16 @@ import {
RECAPTCHA_SITE_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
import { SurveyCompletedMessage } from "@/modules/survey/link/components/survey-completed-message";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { TEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
interface SurveyRendererProps {
survey: TSurvey;
@@ -26,31 +27,13 @@ interface SurveyRendererProps {
lang?: string;
embed?: string;
preview?: string;
suId?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
singleUseResponse?: Pick<Response, "id" | "finished"> | undefined;
contactId?: string;
isPreview: boolean;
// New props - pre-fetched in parent
environmentContext: TEnvironmentContextForLinkSurvey;
locale: TUserLocale;
isMultiLanguageAllowed: boolean;
responseCount?: number;
}
/**
* Renders link survey with pre-fetched data from parent.
*
* This function receives all necessary data as props to avoid additional
* database queries. The parent (page.tsx) fetches data in parallel stages
* to minimize latency for users geographically distant from servers.
*
* @param environmentContext - Pre-fetched project and organization data
* @param locale - User's locale from Accept-Language header
* @param isMultiLanguageAllowed - Calculated from organization billing plan
* @param responseCount - Conditionally fetched if showResponseCount is enabled
*/
export const renderSurvey = async ({
survey,
searchParams,
@@ -58,11 +41,8 @@ export const renderSurvey = async ({
singleUseResponse,
contactId,
isPreview,
environmentContext,
locale,
isMultiLanguageAllowed,
responseCount,
}: SurveyRendererProps) => {
const locale = await findMatchingLocale();
const langParam = searchParams.lang;
const isEmbed = searchParams.embed === "true";
@@ -70,27 +50,27 @@ export const renderSurvey = async ({
notFound();
}
// Extract project from pre-fetched context
const { project } = environmentContext;
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress") {
const project = await getProjectByEnvironmentId(survey.environmentId);
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage}
project={project}
project={project || undefined}
/>
);
}
// Check if single-use survey has already been completed
if (singleUseResponse?.finished) {
return <SurveyCompletedMessage singleUseMessage={survey.singleUse} project={project} />;
}
// Handle email verification flow if enabled
// verify email: Check if the survey requires email verification
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
@@ -104,42 +84,40 @@ export const renderSurvey = async ({
}
}
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
if (emailVerificationStatus === "fishy") {
return (
<VerifyEmail
survey={survey}
isErrorComponent={true}
languageCode={getLanguageCode(langParam, isMultiLanguageAllowed, survey)}
styling={project.styling}
locale={locale}
/>
);
}
return (
<VerifyEmail
singleUseId={searchParams.suId ?? ""}
survey={survey}
languageCode={getLanguageCode(langParam, isMultiLanguageAllowed, survey)}
styling={project.styling}
locale={locale}
/>
);
// get project
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
}
// Compute final styling based on project and survey settings
const styling = computeStyling(project.styling, survey.styling);
const languageCode = getLanguageCode(langParam, isMultiLanguageAllowed, survey);
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
const publicDomain = getPublicDomain();
// Handle PIN-protected surveys
if (survey.pin) {
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
styling={styling}
publicDomain={publicDomain}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
IMPRINT_URL={IMPRINT_URL}
@@ -148,74 +126,35 @@ export const renderSurvey = async ({
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
contactId={contactId}
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
isSpamProtectionEnabled={isSpamProtectionEnabled}
responseCount={responseCount}
/>
);
}
// Render interactive survey with client component for interactivity
return (
<SurveyClientWrapper
<LinkSurvey
survey={survey}
project={project}
styling={styling}
publicDomain={publicDomain}
responseCount={responseCount}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponse?.id}
contactId={contactId}
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
isSpamProtectionEnabled={isSpamProtectionEnabled}
isPreview={isPreview}
verifiedEmail={verifiedEmail}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
);
};
/**
* Determines which styling to use based on project and survey settings.
* Returns survey styling if theme overwriting is enabled, otherwise returns project styling.
*/
function computeStyling(
projectStyling: TProjectStyling,
surveyStyling?: TSurveyStyling | null
): TProjectStyling | TSurveyStyling {
if (!projectStyling.allowStyleOverwrite) {
return projectStyling;
}
return surveyStyling?.overwriteThemeStyling ? surveyStyling : projectStyling;
}
/**
* Determines the language code to use for the survey.
* Checks URL parameter against available survey languages and returns
* "default" if multi-language is not allowed or language is not found.
*/
function getLanguageCode(
langParam: string | undefined,
isMultiLanguageAllowed: boolean,
survey: TSurvey
): string {
if (!langParam || !isMultiLanguageAllowed) return "default";
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}

View File

@@ -1,15 +1,11 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
@@ -97,41 +93,18 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
singleUseId = validatedSingleUseId;
}
// Parallel fetch of environment context and locale
const [environmentContext, locale, singleUseResponse] = await Promise.all([
getEnvironmentContextForLinkSurvey(survey.environmentId),
findMatchingLocale(),
// Fetch existing response for this contact
getExistingContactResponse(survey.id, contactId)(),
]);
// Get multi-language permission
const isMultiLanguageAllowed = await getMultiLanguagePermission(
environmentContext.organizationBilling.plan
);
// Fetch responseCount only if needed
const responseCount = survey.welcomeCard.showResponseCount
? await getResponseCountBySurveyId(survey.id)
: undefined;
return renderSurvey({
survey,
searchParams,
contactId,
isPreview,
singleUseId,
singleUseResponse,
environmentContext,
locale,
isMultiLanguageAllowed,
responseCount,
});
};

View File

@@ -398,7 +398,7 @@ describe("data", () => {
});
});
test("should return undefined when contact response not found", async () => {
test("should return null when contact response not found", async () => {
const surveyId = "survey-1";
const contactId = "nonexistent-contact";
@@ -406,7 +406,7 @@ describe("data", () => {
const result = await getExistingContactResponse(surveyId, contactId)();
expect(result).toBeUndefined();
expect(result).toBeNull();
});
test("should throw DatabaseError on Prisma error", async () => {

View File

@@ -57,7 +57,6 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
surveyClosedMessage: true,
showLanguageSwitch: true,
recaptcha: true,
metadata: true,
// Related data
languages: {
@@ -67,11 +66,11 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
code: true,
projectId: true,
alias: true,
},
},
},
@@ -94,15 +93,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
},
},
segment: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
title: true,
description: true,
isPrivate: true,
filters: true,
include: {
surveys: {
select: {
id: true,
@@ -217,7 +208,7 @@ export const getExistingContactResponse = reactCache((surveyId: string, contactI
},
});
return response ?? undefined;
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -1,221 +0,0 @@
import { Prisma } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getEnvironmentContextForLinkSurvey } from "./environment";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
// Mock React cache
vi.mock("react", () => ({
cache: vi.fn((fn) => fn),
}));
describe("getEnvironmentContextForLinkSurvey", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("should successfully fetch environment context with all required data", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9i";
const mockData = {
project: {
id: "clh1a2b3c4d5e6f7g8h9j",
name: "Test Project",
styling: { primaryColor: "#000000" },
logo: { url: "https://example.com/logo.png" },
linkSurveyBranding: true,
organizationId: "clh1a2b3c4d5e6f7g8h9k",
organization: {
id: "clh1a2b3c4d5e6f7g8h9k",
billing: {
plan: "free",
limits: {
monthly: {
responses: 100,
miu: 1000,
},
},
features: {
inAppSurvey: {
status: "active",
},
linkSurvey: {
status: "active",
},
},
},
},
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
const result = await getEnvironmentContextForLinkSurvey(mockEnvironmentId);
expect(result).toEqual({
project: {
id: "clh1a2b3c4d5e6f7g8h9j",
name: "Test Project",
styling: { primaryColor: "#000000" },
logo: { url: "https://example.com/logo.png" },
linkSurveyBranding: true,
},
organizationId: "clh1a2b3c4d5e6f7g8h9k",
organizationBilling: mockData.project.organization.billing,
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: {
project: {
select: {
id: true,
name: true,
styling: true,
logo: true,
linkSurveyBranding: true,
organizationId: true,
organization: {
select: {
id: true,
billing: true,
},
},
},
},
},
});
});
test("should throw ValidationError for invalid environment ID", async () => {
const invalidId = "invalid-id";
await expect(getEnvironmentContextForLinkSurvey(invalidId)).rejects.toThrow(ValidationError);
});
test("should throw ResourceNotFoundError when environment has no project", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9m";
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
project: null,
} as any);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
ResourceNotFoundError
);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Project");
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
const mockEnvironmentId = "cuid123456789012345";
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
ResourceNotFoundError
);
});
test("should throw ResourceNotFoundError when project has no organization", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9n";
const mockData = {
project: {
id: "clh1a2b3c4d5e6f7g8h9o",
name: "Test Project",
styling: {},
logo: null,
linkSurveyBranding: true,
organizationId: "clh1a2b3c4d5e6f7g8h9p",
organization: null,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
ResourceNotFoundError
);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Organization");
});
test("should throw DatabaseError on Prisma error", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9q";
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(DatabaseError);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Database error");
});
test("should rethrow non-Prisma errors", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9r";
const genericError = new Error("Generic error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(genericError);
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(genericError);
});
test("should handle project with minimal data", async () => {
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9s";
const mockData = {
project: {
id: "clh1a2b3c4d5e6f7g8h9t",
name: "Minimal Project",
styling: null,
logo: null,
linkSurveyBranding: false,
organizationId: "clh1a2b3c4d5e6f7g8h9u",
organization: {
id: "clh1a2b3c4d5e6f7g8h9u",
billing: {
plan: "free",
limits: {
monthly: {
responses: 100,
miu: 1000,
},
},
features: {
inAppSurvey: {
status: "inactive",
},
linkSurvey: {
status: "inactive",
},
},
},
},
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
const result = await getEnvironmentContextForLinkSurvey(mockEnvironmentId);
expect(result).toEqual({
project: {
id: "clh1a2b3c4d5e6f7g8h9t",
name: "Minimal Project",
styling: null,
logo: null,
linkSurveyBranding: false,
},
organizationId: "clh1a2b3c4d5e6f7g8h9u",
organizationBilling: mockData.project.organization.billing,
});
});
});

View File

@@ -1,103 +0,0 @@
import "server-only";
import { Prisma, Project } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { validateInputs } from "@/lib/utils/validate";
/**
* @file Data access layer for link surveys - optimized environment context fetching
* @module modules/survey/link/lib/environment
*
* This module provides optimized data fetching for link survey rendering by combining
* related queries into a single database call. Uses React cache for automatic request
* deduplication within the same render cycle.
*/
type TProjectForLinkSurvey = Pick<Project, "id" | "name" | "styling" | "logo" | "linkSurveyBranding">;
export interface TEnvironmentContextForLinkSurvey {
project: TProjectForLinkSurvey;
organizationId: string;
organizationBilling: TOrganizationBilling;
}
/**
* Fetches all environment-related data needed for link surveys in a single optimized query.
* Combines project, organization, and billing data using Prisma relationships to minimize
* database round trips.
*
* This function is specifically optimized for link survey rendering and only fetches the
* fields required for that use case. Other parts of the application may need different
* field combinations and should use their own specialized functions.
*
* @param environmentId - The environment identifier
* @returns Object containing project styling data, organization ID, and billing information
* @throws ResourceNotFoundError if environment, project, or organization not found
* @throws DatabaseError if database query fails
*
* @example
* ```typescript
* // In server components, function is automatically cached per request
* const { project, organizationId, organizationBilling } =
* await getEnvironmentContextForLinkSurvey(survey.environmentId);
* ```
*/
export const getEnvironmentContextForLinkSurvey = reactCache(
async (environmentId: string): Promise<TEnvironmentContextForLinkSurvey> => {
validateInputs([environmentId, ZId]);
try {
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
project: {
select: {
id: true,
name: true,
styling: true,
logo: true,
linkSurveyBranding: true,
organizationId: true,
organization: {
select: {
id: true,
billing: true,
},
},
},
},
},
});
// Fail early pattern: validate data before proceeding
if (!environment?.project) {
throw new ResourceNotFoundError("Project", null);
}
if (!environment.project.organization) {
throw new ResourceNotFoundError("Organization", null);
}
// Return structured, typed data
return {
project: {
id: environment.project.id,
name: environment.project.name,
styling: environment.project.styling,
logo: environment.project.logo,
linkSurveyBranding: environment.project.linkSurveyBranding,
},
organizationId: environment.project.organizationId,
organizationBilling: environment.project.organization.billing as TOrganizationBilling,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -19,21 +19,16 @@ export const getNameForURL = (value: string) => encodeURIComponent(value);
export const getBrandColorForURL = (value: string) => encodeURIComponent(value);
/**
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name.
*
* @param surveyId - Survey identifier
* @param languageCode - Language code for localization (default: "default")
* @param survey - Optional survey data if already available (e.g., from generateMetadata)
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name
*/
export const getBasicSurveyMetadata = async (
surveyId: string,
languageCode = "default",
survey?: Awaited<ReturnType<typeof getSurvey>> | null
languageCode = "default"
): Promise<TBasicSurveyMetadata> => {
const surveyData = survey ?? (await getSurvey(surveyId));
const survey = await getSurvey(surveyId);
// If survey doesn't exist, return default metadata
if (!surveyData) {
if (!survey) {
return {
title: "Survey",
description: "Please complete this survey.",
@@ -42,11 +37,11 @@ export const getBasicSurveyMetadata = async (
};
}
const metadata = surveyData.metadata;
const welcomeCard = surveyData.welcomeCard;
const metadata = survey.metadata;
const welcomeCard = survey.welcomeCard;
const useDefaultLanguageCode =
languageCode === "default" ||
surveyData.languages.find((lang) => lang.language.code === languageCode)?.default;
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
// Determine language code to use for metadata
const langCode = useDefaultLanguageCode ? "default" : languageCode;
@@ -56,10 +51,10 @@ export const getBasicSurveyMetadata = async (
const titleFromWelcome =
welcomeCard?.enabled && welcomeCard.headline
? getTextContent(
getLocalizedValue(recallToHeadline(welcomeCard.headline, surveyData, false, langCode), langCode)
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
) || ""
: undefined;
let title = titleFromMetadata || titleFromWelcome || surveyData.name;
let title = titleFromMetadata || titleFromWelcome || survey.name;
// Set description - priority: custom link metadata > default
const descriptionFromMetadata = metadata?.description
@@ -68,7 +63,7 @@ export const getBasicSurveyMetadata = async (
let description = descriptionFromMetadata || "Please complete this survey.";
// Get OG image from link metadata if available
const ogImage = metadata?.ogImage;
const { ogImage } = metadata;
if (!titleFromMetadata) {
if (IS_FORMBRICKS_CLOUD) {
@@ -79,7 +74,7 @@ export const getBasicSurveyMetadata = async (
return {
title,
description,
survey: surveyData,
survey,
ogImage,
};
};

View File

@@ -1,11 +1,11 @@
import { notFound } from "next/navigation";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
import { getMetadataForLinkSurvey } from "./metadata";
vi.mock("@/modules/survey/link/lib/data", () => ({
getSurveyWithMetadata: vi.fn(),
getSurveyMetadata: vi.fn(),
}));
vi.mock("next/navigation", () => ({
@@ -54,12 +54,12 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
const result = await getMetadataForLinkSurvey(mockSurveyId);
expect(getSurveyWithMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined, mockSurvey);
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
expect(result).toEqual({
@@ -98,7 +98,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getBasicSurveyMetadata).mockResolvedValue({
title: mockSurveyName,
description: mockDescription,
@@ -120,7 +120,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
};
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey as any);
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey as any);
await getMetadataForLinkSurvey(mockSurveyId);
@@ -135,7 +135,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "draft",
} as any;
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
await getMetadataForLinkSurvey(mockSurveyId);
@@ -150,7 +150,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
twitter: {
title: mockSurveyName,
@@ -192,7 +192,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
openGraph: {
title: mockSurveyName,

View File

@@ -1,19 +1,20 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
export const getMetadataForLinkSurvey = async (
surveyId: string,
languageCode?: string
): Promise<Metadata> => {
const survey = await getSurveyWithMetadata(surveyId);
const survey = await getSurveyMetadata(surveyId);
if (!survey || survey?.type !== "link" || survey?.status === "draft") {
if (!survey || survey.type !== "link" || survey.status === "draft") {
notFound();
}
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode, survey);
// Get enhanced metadata that includes custom link metadata
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode);
const surveyBrandColor = survey.styling?.brandColor?.light;
// Use the shared function for creating the base metadata but override with custom data

View File

@@ -3,14 +3,11 @@ import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys/types";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
interface LinkSurveyPageProps {
@@ -50,29 +47,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
const isPreview = searchParams.preview === "true";
/**
* Optimized data fetching strategy for link surveys
*
* PERFORMANCE OPTIMIZATION:
* We fetch data in carefully staged parallel operations to minimize latency.
* Each sequential database call adds ~100-300ms for users far from servers.
*
* Fetch stages:
* Stage 1: Survey (required first - provides config for all other fetches)
* Stage 2: Parallel fetch of environment context, locale, and conditional single-use response
* Stage 3: Multi-language permission (depends on billing from Stage 2)
*
* This reduces waterfall from 4-5 levels to 3 levels:
* - Before: ~400-1500ms added latency for distant users
* - After: ~200-600ms added latency for distant users
* - Improvement: 50-60% latency reduction
*
* CACHING NOTE:
* getSurveyWithMetadata is wrapped in React's cache(), so the call from
* generateMetadata and this page component are automatically deduplicated.
*/
// Stage 1: Fetch survey first (required for all subsequent logic)
// Use optimized survey data fetcher (includes all necessary data)
let survey: TSurvey | null = null;
try {
survey = await getSurveyWithMetadata(params.surveyId);
@@ -81,60 +56,40 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
return notFound();
}
if (!survey) {
return notFound();
}
const suId = searchParams.suId;
// Validate single-use ID early (no I/O, just validation)
const isSingleUseSurvey = survey.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey.singleUse?.isEncrypted;
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
// Need to fetch project for error page - fetch environmentContext for it
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
singleUseId = validatedSingleUseId;
}
// Stage 2: Parallel fetch of all remaining data
const [environmentContext, locale, singleUseResponse] = await Promise.all([
getEnvironmentContextForLinkSurvey(survey.environmentId),
findMatchingLocale(),
// Only fetch single-use response if we have a validated ID
isSingleUseSurvey && singleUseId
? getResponseBySingleUseId(survey.id, singleUseId)()
: Promise.resolve(undefined),
]);
let singleUseResponse;
if (isSingleUseSurvey && singleUseId) {
try {
// Use optimized response fetcher with proper caching
const fetchResponseFn = getResponseBySingleUseId(survey.id, singleUseId);
singleUseResponse = await fetchResponseFn();
} catch (error) {
logger.error("Error fetching single use response:", error);
singleUseResponse = undefined;
}
}
// Stage 3: Get multi-language permission (depends on environmentContext)
// Future optimization: Consider caching getMultiLanguagePermission by plan tier
// since it's a pure computation based on billing plan. Could be memoized at
// the plan level rather than per-request.
const isMultiLanguageAllowed = await getMultiLanguagePermission(
environmentContext.organizationBilling.plan
);
// Fetch responseCount only if needed (depends on survey config)
const responseCount = survey.welcomeCard.showResponseCount
? await getResponseCountBySurveyId(survey.id)
: undefined;
// Pass all pre-fetched data to renderer
return renderSurvey({
survey,
searchParams,
singleUseId,
singleUseResponse: singleUseResponse ?? undefined,
singleUseResponse,
isPreview,
environmentContext,
locale,
isMultiLanguageAllowed,
responseCount,
});
};

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { convertDateString, timeSince } from "@/lib/time";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
@@ -47,6 +48,8 @@ export const SurveyCard = ({
const isSurveyCreationDeletionDisabled = isReadOnly;
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const linkHref = useMemo(() => {
return survey.status === "draft"
? `/environments/${environmentId}/surveys/${survey.id}/edit`
@@ -98,6 +101,7 @@ export const SurveyCard = ({
environmentId={environmentId}
publicDomain={publicDomain}
disabled={isDraftAndReadOnly}
refreshSingleUseId={refreshSingleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
deleteSurvey={deleteSurvey}
onSurveysCopied={onSurveysCopied}

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -39,6 +39,7 @@ interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurvey;
publicDomain: string;
refreshSingleUseId: () => Promise<string | undefined>;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => void;
@@ -49,6 +50,7 @@ export const SurveyDropDownMenu = ({
environmentId,
survey,
publicDomain,
refreshSingleUseId,
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
@@ -60,11 +62,26 @@ export const SurveyDropDownMenu = ({
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const [newSingleUseId, setNewSingleUseId] = useState<string | undefined>(undefined);
const router = useRouter();
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
// Pre-fetch single-use ID when dropdown opens to avoid async delay during clipboard operation
// This ensures Safari's clipboard API works by maintaining the user gesture context
useEffect(() => {
if (!isDropDownOpen) return;
const fetchNewId = async () => {
try {
const newId = await refreshSingleUseId();
setNewSingleUseId(newId ?? undefined);
} catch (error) {
logger.error(error);
}
};
fetchNewId();
}, [refreshSingleUseId, isDropDownOpen]);
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
@@ -83,8 +100,7 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
const copiedLink = copySurveyLink(surveyLink, newSingleUseId);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
@@ -189,36 +205,31 @@ export const SurveyDropDownMenu = ({
<>
<DropdownMenuItem>
<button
type="button"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
className="flex w-full cursor-pointer items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
const previewUrl = surveyLink + "?preview=true";
const newId = await refreshSingleUseId();
const previewUrl =
surveyLink + (newId ? `?suId=${newId}&preview=true` : "?preview=true");
window.open(previewUrl, "_blank");
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview_survey")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
{!survey.singleUse?.enabled && (
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className="flex w-full items-center"
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
)}
</>
)}
{!isSurveyCreationDeletionDisabled && (

View File

@@ -7,9 +7,8 @@ import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { SortOption } from "@/modules/survey/list/components/sort-option";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { initialFilters } from "@/modules/survey/list/components/survey-list";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -155,13 +154,12 @@ export const SurveyFilters = ({
</div>
)}
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
{(createdBy.length > 0 || status.length > 0 || type.length > 0) && (
<Button
size="sm"
onClick={() => {
setSurveyFilters(initialFilters);
setName(""); // Also clear the search input
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
localStorage.removeItem("surveyFilters");
}}
className="h-8">
{t("common.clear_filters")}

View File

@@ -10,7 +10,6 @@ import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getSurveysAction } from "@/modules/survey/list/actions";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { getFormattedFilters } from "@/modules/survey/list/lib/utils";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { Button } from "@/modules/ui/components/button";
@@ -28,6 +27,14 @@ interface SurveysListProps {
locale: TUserLocale;
}
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};
export const SurveysList = ({
environmentId,
isReadOnly,
@@ -39,18 +46,14 @@ export const SurveysList = ({
}: SurveysListProps) => {
const router = useRouter();
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [isFetching, setIsFetching] = useState(true);
const [hasMore, setHasMore] = useState<boolean>(true);
const [refreshTrigger, setRefreshTrigger] = useState(false);
const { t } = useTranslation();
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
const { name, createdBy, status, type, sortBy } = surveyFilters;
const filters = useMemo(
() => getFormattedFilters(surveyFilters, userId),
[name, JSON.stringify(createdBy), JSON.stringify(status), JSON.stringify(type), sortBy, userId]
);
const filters = useMemo(() => getFormattedFilters(surveyFilters, userId), [surveyFilters, userId]);
const [parent] = useAutoAnimate();
useEffect(() => {
@@ -77,30 +80,28 @@ export const SurveysList = ({
}, [surveyFilters, isFilterInitialized]);
useEffect(() => {
// Wait for filters to be loaded from localStorage before fetching
if (!isFilterInitialized) return;
const fetchFilteredSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
if (isFilterInitialized) {
const fetchInitialSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
setSurveys(res.data);
setIsFetching(false);
}
setSurveys(res.data);
setIsFetching(false);
}
};
fetchFilteredSurveys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environmentId, surveysLimit, filters, refreshTrigger, isFilterInitialized]);
};
fetchInitialSurveys();
}
}, [environmentId, surveysLimit, filters, isFilterInitialized, refreshTrigger]);
const fetchNextPage = useCallback(async () => {
setIsFetching(true);

View File

@@ -1,9 +0,0 @@
import { TSurveyFilters } from "@formbricks/types/surveys/types";
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};

View File

@@ -1,37 +1,13 @@
import { Session } from "next-auth";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
interface EnvironmentIdBaseLayoutProps {
children: React.ReactNode;
environmentId: string;
session: Session;
user: TUser;
organization: TOrganization;
}
export const EnvironmentIdBaseLayout = async ({
children,
environmentId,
session,
user,
organization,
}: EnvironmentIdBaseLayoutProps) => {
export const EnvironmentIdBaseLayout = async ({ children }: EnvironmentIdBaseLayoutProps) => {
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient />
{children}
</ResponseFilterProvider>

View File

@@ -1,56 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import React, { type JSX, useEffect } from "react";
interface PostHogPageviewProps {
posthogEnabled: boolean;
postHogApiHost?: string;
postHogApiKey?: string;
}
export const PostHogPageview = ({
posthogEnabled,
postHogApiHost,
postHogApiKey,
}: PostHogPageviewProps): JSX.Element => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!posthogEnabled) return;
try {
if (!postHogApiHost) {
throw new Error("Posthog API host is required");
}
if (!postHogApiKey) {
throw new Error("Posthog key is required");
}
posthog.init(postHogApiKey, { api_host: postHogApiHost });
} catch (error) {
console.error("Failed to initialize PostHog:", error);
}
}, []);
useEffect(() => {
if (!posthogEnabled) return;
let url = window.origin + pathname;
if (searchParams?.toString()) {
url += `?${searchParams.toString()}`;
}
posthog.capture("$pageview", { $current_url: url });
}, [pathname, searchParams, posthogEnabled]);
return <></>;
};
interface PHPProviderProps {
children: React.ReactNode;
posthogEnabled: boolean;
}
export const PHProvider = ({ children, posthogEnabled }: PHPProviderProps) => {
return posthogEnabled ? <PostHogProvider client={posthog}>{children}</PostHogProvider> : children;
};

View File

@@ -1,12 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { executeRecaptcha, loadRecaptchaScript } from "@/modules/ui/components/survey/recaptcha";
const createContainerId = () => `formbricks-survey-container`;
// Module-level flag to prevent concurrent script loads across component instances
let isLoadingScript = false;
declare global {
interface Window {
formbricksSurveys: {
@@ -30,11 +26,8 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
[containerId, props, getRecaptchaToken]
);
const [isScriptLoaded, setIsScriptLoaded] = useState(false);
const hasLoadedRef = useRef(false);
const loadSurveyScript: () => Promise<void> = async () => {
// Set loading flag immediately to prevent concurrent loads
isLoadingScript = true;
try {
const response = await fetch("/js/surveys.umd.cjs");
@@ -49,20 +42,12 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
document.head.appendChild(scriptElement);
setIsScriptLoaded(true);
hasLoadedRef.current = true;
} catch (error) {
throw error;
} finally {
isLoadingScript = false;
}
};
useEffect(() => {
// Prevent duplicate loads across multiple renders or component instances
if (hasLoadedRef.current || isLoadingScript) {
return;
}
const loadScript = async () => {
if (!window.formbricksSurveys) {
try {
@@ -79,8 +64,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
};
loadScript();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
}, [containerId, props, renderInline]);
useEffect(() => {
if (isScriptLoaded) {

View File

@@ -110,10 +110,6 @@ export const ThemeStylingPreviewSurvey = ({
const isAppSurvey = previewType === "app";
// Create a unique key that includes both timestamp and preview type
// This ensures the survey remounts when switching between app and link
const surveyKey = `${previewType}-${surveyFormKey}`;
const scrollToEditLogoSection = () => {
const editLogoSection = document.getElementById("edit-logo");
if (editLogoSection) {
@@ -164,7 +160,7 @@ export const ThemeStylingPreviewSurvey = ({
previewMode="desktop"
background={project.styling.cardBackgroundColor?.light}
borderRadius={project.styling.roundness ?? 8}>
<Fragment key={surveyKey}>
<Fragment key={surveyFormKey}>
<SurveyInline
isPreviewMode={true}
survey={{ ...survey, type: "app" }}
@@ -189,7 +185,7 @@ export const ThemeStylingPreviewSurvey = ({
</button>
)}
<div
key={surveyKey}
key={surveyFormKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
isPreviewMode={true}

View File

@@ -108,8 +108,6 @@
"nodemailer": "7.0.9",
"otplib": "12.0.1",
"papaparse": "5.5.2",
"posthog-js": "1.240.0",
"posthog-node": "5.9.2",
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",

View File

@@ -57,7 +57,6 @@ export default defineConfig({
"**/actions.ts", // Server actions (plural)
"**/action.ts", // Server actions (singular)
"lib/env.ts", // Environment configuration
"lib/posthogServer.ts", // PostHog server integration
"**/cache.ts", // Cache files
"**/cache/**", // Cache directories

View File

@@ -186,9 +186,6 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
vi.mock("@/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",

View File

@@ -52,7 +52,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |

View File

@@ -203,9 +203,8 @@ export function Survey({
const getShowSurveyCloseButton = (offset: number) => {
return offset === 0 && localSurvey.type !== "link";
};
const enabledLanguages = localSurvey.languages.filter((lang) => lang.enabled);
const getShowLanguageSwitch = (offset: number) => {
return localSurvey.showLanguageSwitch && enabledLanguages.length > 1 && offset <= 0;
return localSurvey.showLanguageSwitch && localSurvey.languages.length > 0 && offset <= 0;
};
const onFileUpload = async (file: TJsFileUploadParams["file"], params?: TUploadFileConfig) => {

View File

@@ -172,8 +172,6 @@
"OIDC_SIGNING_ALGORITHM",
"PASSWORD_RESET_DISABLED",
"PLAYWRIGHT_CI",
"POSTHOG_API_HOST",
"POSTHOG_API_KEY",
"PRIVACY_URL",
"RATE_LIMITING_DISABLED",
"REDIS_URL",
@@ -203,7 +201,6 @@
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"PUBLIC_URL",
"TELEMETRY_DISABLED",
"TURNSTILE_SECRET_KEY",
"TURNSTILE_SITE_KEY",
"RECAPTCHA_SITE_KEY",