mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-19 00:23:35 -05:00
Compare commits
2 Commits
release/4.
...
better-ee-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
256a0ec81a | ||
|
|
58ab40ab8e |
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "終了画面カード",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "结束 屏幕 卡片",
|
||||
|
||||
@@ -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": "結束畫面卡片",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal file
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal file
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const initialFilters: TSurveyFilters = {
|
||||
name: "",
|
||||
createdBy: [],
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user