Compare commits

..

5 Commits

Author SHA1 Message Date
Dhruwang acd6d7185e feat: czech translations 2025-11-19 17:45:41 +05:30
Matti Nannt f6683d1165 fix: optimize survey list performance with client-side filtering (#6812)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-19 06:36:07 +00:00
Matti Nannt 13be7a8970 perf: Optimize link survey with server/client component architecture (#6764)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-19 06:31:41 +00:00
Dhruwang Jariwala 0472d5e8f0 fix: language switch tweak and docs feedback template (#6811) 2025-11-18 17:00:23 +00:00
Dhruwang Jariwala 00a61f7abe chore: response page optimization (#6843)
Co-authored-by: igor-srdoc <igor@srdoc.si>
2025-11-18 16:50:48 +00:00
98 changed files with 4772 additions and 1395 deletions
@@ -1,6 +1,8 @@
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";
@@ -38,6 +40,14 @@ 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 } = await environmentIdLayoutChecks(params.environmentId);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
@@ -25,7 +25,11 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<EnvironmentIdBaseLayout>
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
@@ -0,0 +1,61 @@
"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,7 +24,11 @@ const EnvLayout = async (props: {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<EnvironmentIdBaseLayout>
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
@@ -26,6 +26,7 @@ interface ResponsePageProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
initialResponses?: TResponseWithQuotas[];
}
export const ResponsePage = ({
@@ -39,11 +40,12 @@ export const ResponsePage = ({
isReadOnly,
isQuotasAllowed,
quotas,
initialResponses = [],
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
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 { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
@@ -56,6 +58,7 @@ export const ResponsePage = ({
const searchParams = useSearchParams();
const fetchNextPage = useCallback(async () => {
if (page === null) return;
const newPage = page + 1;
let newResponses: TResponseWithQuotas[] = [];
@@ -94,9 +97,14 @@ export const ResponsePage = ({
}, [searchParams, resetState]);
useEffect(() => {
const fetchInitialResponses = async () => {
const fetchFilteredResponses = async () => {
try {
setFetchingFirstPage(true);
// skip call for initial mount
if (page === null) {
setPage(1);
return;
}
setIsFetchingFirstPage(true);
let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
@@ -110,19 +118,24 @@ export const ResponsePage = ({
if (responses.length < responsesPerPage) {
setHasMore(false);
} else {
setHasMore(true);
}
setResponses(responses);
} finally {
setFetchingFirstPage(false);
setIsFetchingFirstPage(false);
}
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
setHasMore(true);
}, [filters]);
// Only fetch if filters are applied (not on initial mount with no filters)
const hasFilters =
(selectedFilter && Object.keys(selectedFilter).length > 0) ||
(dateRange && (dateRange.from || dateRange.to));
if (hasFilters) {
fetchFilteredResponses();
}
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
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 } from "@/lib/response/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
@@ -56,6 +56,9 @@ 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
@@ -87,6 +90,7 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
initialResponses={initialResponses}
/>
</PageContentWrapper>
);
+17 -3
View File
@@ -1,9 +1,12 @@
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 }) => {
@@ -18,9 +21,20 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
<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>
</>
);
};
+34
View File
@@ -0,0 +1,34 @@
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,6 +18,10 @@ 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";
@@ -54,7 +58,19 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
// Limit check completed
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`);
}
}
return isLimitReached;
};
@@ -95,7 +111,10 @@ export const GET = withV1ApiWrapper({
}
if (!environment.appSetupCompleted) {
await updateEnvironment(environment.id, { appSetupCompleted: true });
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
// check organization subscriptions and response limits
@@ -5,6 +5,7 @@ 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";
@@ -58,6 +59,7 @@ 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,11 +8,16 @@ 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(),
@@ -38,6 +43,7 @@ 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",
}));
@@ -182,7 +188,9 @@ 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 () => {
@@ -218,6 +226,7 @@ describe("getEnvironmentState", () => {
where: { id: environmentId },
data: { appSetupCompleted: true },
});
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.data).toBeDefined();
});
@@ -228,6 +237,16 @@ 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 () => {
@@ -237,6 +256,21 @@ 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 () => {
@@ -279,6 +313,7 @@ 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 () => {
@@ -296,6 +331,21 @@ 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,10 +1,15 @@
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";
/**
@@ -28,10 +33,13 @@ 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 prisma.environment.update({
where: { id: environmentId },
data: { appSetupCompleted: true },
});
await Promise.all([
prisma.environment.update({
where: { id: environmentId },
data: { appSetupCompleted: true },
}),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
// Check monthly response limits for Formbricks Cloud
@@ -42,7 +50,23 @@ export const getEnvironmentState = async (
isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
// Limit check completed
// 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");
}
}
}
// Build the response data
@@ -9,6 +9,7 @@ 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";
@@ -27,10 +28,18 @@ 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(),
}));
@@ -136,6 +145,26 @@ 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 () => {
@@ -157,6 +186,20 @@ 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,9 +6,11 @@ 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";
@@ -81,6 +83,7 @@ export const createResponse = async (
tx: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -118,6 +121,8 @@ 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,6 +10,7 @@ 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";
@@ -171,6 +172,11 @@ export const POST = withV1ApiWrapper({
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
@@ -8,6 +8,7 @@ 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";
@@ -95,6 +96,9 @@ 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",
@@ -114,8 +118,10 @@ 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: {
@@ -228,9 +234,10 @@ describe("Response Lib Tests", () => {
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
});
test("should check response limit if limit not reached", async () => {
test("should check response limit and not send event if limit not reached", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
@@ -244,6 +251,32 @@ 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,12 +8,14 @@ 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";
@@ -91,6 +93,7 @@ export const createResponse = async (
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -128,6 +131,8 @@ 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,6 +3,7 @@ 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";
@@ -48,6 +49,7 @@ 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,7 +12,9 @@ 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";
@@ -47,7 +49,9 @@ 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", () => ({
@@ -162,7 +166,9 @@ 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,
@@ -177,6 +183,26 @@ 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 () => {
@@ -199,6 +225,20 @@ 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,10 +6,12 @@ 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";
@@ -89,6 +91,7 @@ export const createResponse = async (
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
@@ -126,6 +129,8 @@ 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,6 +8,7 @@ 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";
@@ -147,6 +148,11 @@ 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 = {
+1 -1
View File
@@ -1504,7 +1504,7 @@ const docsFeedback = (t: TFunction): TTemplate => {
buildOpenTextQuestion({
headline: t("templates.docs_feedback_question_2_headline"),
required: false,
inputType: "text",
inputType: "url",
t,
}),
buildOpenTextQuestion({
+2 -1
View File
@@ -17,7 +17,8 @@
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
"es-ES",
"cs-CZ"
]
},
"version": 1.8
+3 -4
View File
@@ -1252,7 +1252,7 @@ checksums:
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
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
@@ -1891,9 +1891,8 @@ checksums:
setup/organization/create/no_membership_found: 6d6e792661c79452984dc671a2590847
setup/organization/create/no_membership_found_description: ba067dacf419041c8bcdb51ee6b0125b
setup/organization/create/title: 771924a960b2f7019cf8db55543c0715
setup/signup/create_administrator: bb44729d41138e400802509641de1d46
setup/signup/receive_security_updates: de5127f5847cdd412906607e1402f48d
setup/signup/security_updates_consent_description: 4643df07f13cec619e7fd91c8f14d93b
setup/signup/create_administrator: e5b7e90150ebecf18a248f50a011bb7f
setup/signup/this_user_has_all_the_power: 1af3a7367d412d17f0f16c0e104b3520
templates/address: 5a9a8bc26f90d84c90105690a2eb23a1
templates/address_description: e45b92c5cfff59ae7381e8bfb43b173f
templates/alignment_and_engagement_survey_description: a959f7abf4c7bc55371381a73d3113aa
+5
View File
@@ -176,6 +176,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ja-JP",
"zh-Hans-CN",
"es-ES",
"cs-CZ",
];
// Billing constants
@@ -218,6 +219,10 @@ 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);
+6
View File
@@ -59,6 +59,8 @@ 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()
@@ -101,6 +103,7 @@ export const env = createEnv({
}
)
.optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z
.string()
.url()
@@ -169,6 +172,8 @@ 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,
@@ -201,6 +206,7 @@ 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,
+5
View File
@@ -17,6 +17,7 @@ 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";
@@ -172,6 +173,10 @@ export const createEnvironment = async (
},
});
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
environmentType: environment.type,
});
return environment;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
+28
View File
@@ -139,6 +139,7 @@ export const appLanguages = [
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"cs-CZ": "Angličtina (USA)",
},
},
{
@@ -155,6 +156,7 @@ export const appLanguages = [
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
"cs-CZ": "Němčina",
},
},
{
@@ -171,6 +173,7 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"cs-CZ": "Portugalština (Brazílie)",
},
},
{
@@ -187,6 +190,7 @@ export const appLanguages = [
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
"cs-CZ": "Francouzština",
},
},
{
@@ -203,6 +207,7 @@ export const appLanguages = [
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
"cs-CZ": "Čínština (tradiční)",
},
},
{
@@ -219,6 +224,7 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"cs-CZ": "Portugalština (Portugalsko)",
},
},
{
@@ -235,6 +241,7 @@ export const appLanguages = [
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"cs-CZ": "Rumunština",
},
},
{
@@ -251,6 +258,7 @@ export const appLanguages = [
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
"cs-CZ": "Japonština",
},
},
{
@@ -267,6 +275,7 @@ export const appLanguages = [
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"cs-CZ": "Čínština (zjednodušená)",
},
},
{
@@ -283,6 +292,7 @@ export const appLanguages = [
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"cs-CZ": "Holandština",
},
},
{
@@ -299,6 +309,24 @@ export const appLanguages = [
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
"cs-CZ": "Španělština",
},
},
{
code: "cs-CZ",
label: {
"en-US": "Czech",
"de-DE": "Tschechisch",
"pt-BR": "Tcheco",
"fr-FR": "Tchèque",
"zh-Hant-TW": "捷克語",
"pt-PT": "Checo",
"ro-RO": "Cehă",
"ja-JP": "チェコ語",
"zh-Hans-CN": "捷克语",
"nl-NL": "Tsjechisch",
"es-ES": "Checo",
"cs-CZ": "Čeština",
},
},
];
+56
View File
@@ -0,0 +1,56 @@
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
);
+7
View File
@@ -13,6 +13,7 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { evaluateLogic } from "@/lib/surveyLogic/utils";
import {
mockActionClass,
@@ -43,6 +44,11 @@ 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(),
@@ -640,6 +646,7 @@ 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 () => {
+6
View File
@@ -13,6 +13,7 @@ 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";
@@ -672,6 +673,11 @@ 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) {
+38
View File
@@ -0,0 +1,38 @@
/* 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");
}
}
};
+3 -1
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { cs, de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -105,6 +105,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return zhCN;
case "es-ES":
return es;
case "cs-CZ":
return cs;
}
};
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1337,7 +1337,7 @@
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
"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_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
"enable_spam_protection": "Spamschutz",
"end_screen_card": "Abschluss-Karte",
@@ -2036,8 +2036,7 @@
},
"signup": {
"create_administrator": "Administrator erstellen",
"receive_security_updates": "Sicherheitsupdates",
"security_updates_consent_description": "Nur sicherheitsrelevante Informationen, Datenschutzrichtlinie gilt."
"this_user_has_all_the_power": "Dieser Benutzer hat alle Rechte."
}
},
"templates": {
+3 -4
View File
@@ -1337,7 +1337,7 @@
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "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": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
"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",
@@ -2035,9 +2035,8 @@
}
},
"signup": {
"create_administrator": "You're the admin!",
"receive_security_updates": "Security updates",
"security_updates_consent_description": "Security relevant information only, Privacy Policy applies."
"create_administrator": "Create Administrator",
"this_user_has_all_the_power": "This user has all the power."
}
},
"templates": {
+2 -3
View File
@@ -2035,9 +2035,8 @@
}
},
"signup": {
"create_administrator": "¡Eres el administrador!",
"receive_security_updates": "Actualizaciones de seguridad",
"security_updates_consent_description": "Solo información relevante para la seguridad, se aplica la política de privacidad."
"create_administrator": "Crear administrador",
"this_user_has_all_the_power": "Este usuario tiene todo el poder."
}
},
"templates": {
+2 -3
View File
@@ -1337,7 +1337,7 @@
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
"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_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",
@@ -2036,8 +2036,7 @@
},
"signup": {
"create_administrator": "Créer un administrateur",
"receive_security_updates": "Mises à jour de sécurité",
"security_updates_consent_description": "Informations pertinentes pour la sécurité uniquement, la politique de confidentialité s'applique."
"this_user_has_all_the_power": "Cet utilisateur a tout le pouvoir."
}
},
"templates": {
+2 -3
View File
@@ -1337,7 +1337,7 @@
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がいつでも言語を切り替えられるようにします。最低2つのアクティブな言語が必要です。",
"enable_recaptcha_to_protect_your_survey_from_spam": "スパム対策はreCAPTCHA v3を使用してスパム回答をフィルタリングします。",
"enable_spam_protection": "スパム対策",
"end_screen_card": "終了画面カード",
@@ -2036,8 +2036,7 @@
},
"signup": {
"create_administrator": "管理者を作成",
"receive_security_updates": "セキュリティアップデート",
"security_updates_consent_description": "セキュリティに関連する情報のみ、プライバシーポリシーが適用されます。"
"this_user_has_all_the_power": "このユーザーはすべての権限を持っています。"
}
},
"templates": {
+3 -4
View File
@@ -1337,7 +1337,7 @@
"edit_link": "Link bewerken",
"edit_recall": "Bewerken Terugroepen",
"edit_translations": "Bewerk {lang} vertalingen",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Geef deelnemers de mogelijkheid om op elk moment tijdens de enquête van enquêtetaal te wisselen.",
"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_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",
@@ -2035,9 +2035,8 @@
}
},
"signup": {
"create_administrator": "Je bent de beheerder!",
"receive_security_updates": "Beveiligingsupdates",
"security_updates_consent_description": "Alleen informatie relevant voor de beveiliging, privacybeleid is van toepassing."
"create_administrator": "Beheerder aanmaken",
"this_user_has_all_the_power": "Deze gebruiker heeft alle macht."
}
},
"templates": {
+2 -3
View File
@@ -1337,7 +1337,7 @@
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
"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_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",
@@ -2036,8 +2036,7 @@
},
"signup": {
"create_administrator": "Criar Administrador",
"receive_security_updates": "Atualizações de segurança",
"security_updates_consent_description": "Apenas informações relevantes de segurança, Política de Privacidade aplicável."
"this_user_has_all_the_power": "Esse usuário tem todo o poder."
}
},
"templates": {
+2 -3
View File
@@ -1337,7 +1337,7 @@
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
"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_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",
@@ -2036,8 +2036,7 @@
},
"signup": {
"create_administrator": "Criar Administrador",
"receive_security_updates": "Atualizações de segurança",
"security_updates_consent_description": "Apenas informações relevantes para a segurança, aplica-se a Política de Privacidade."
"this_user_has_all_the_power": "Este utilizador tem todo o poder."
}
},
"templates": {
+2 -3
View File
@@ -1337,7 +1337,7 @@
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
"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_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",
@@ -2036,8 +2036,7 @@
},
"signup": {
"create_administrator": "Creare Administrator",
"receive_security_updates": "Actualizări de securitate",
"security_updates_consent_description": "Doar informații relevante pentru securitate, se aplică Politica de confidențialitate."
"this_user_has_all_the_power": "Acest utilizator are toată puterea."
}
},
"templates": {
+2 -3
View File
@@ -1337,7 +1337,7 @@
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允许受访者在调查过程中随时切换语言。需要至少启用两种语言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾 邮件 保护 使用 reCAPTCHA v3 来 过滤 掉 垃圾 响应 。",
"enable_spam_protection": "垃圾 邮件 保护",
"end_screen_card": "结束 屏幕 卡片",
@@ -2036,8 +2036,7 @@
},
"signup": {
"create_administrator": "创建 管理员",
"receive_security_updates": "安全更新",
"security_updates_consent_description": "仅限安全相关信息,适用隐私政策。"
"this_user_has_all_the_power": "此 用户 拥有 所有 权力。"
}
},
"templates": {
+3 -4
View File
@@ -1337,7 +1337,7 @@
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許受訪者隨時切換語言。需要至少啟用兩種語言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
"enable_spam_protection": "垃圾郵件保護",
"end_screen_card": "結束畫面卡片",
@@ -2035,9 +2035,8 @@
}
},
"signup": {
"create_administrator": "您是管理員",
"receive_security_updates": "安全性更新",
"security_updates_consent_description": "僅限安全相關資訊,適用隱私權政策。"
"create_administrator": "建立管理員",
"this_user_has_all_the_power": "此使用者擁有所有權限。"
}
},
"templates": {
@@ -1,10 +1,13 @@
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,
@@ -48,6 +51,8 @@ export const createResponse = async (
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<Result<Response, ApiErrorResponseV2>> => {
captureTelemetry("response created");
const {
surveyId,
displayId,
@@ -121,6 +126,7 @@ export const createResponse = async (
if (!billing.ok) {
return err(billing.error as ApiErrorResponseV2);
}
const billingData = billing.data;
const prismaClient = tx ?? prisma;
@@ -134,7 +140,26 @@ export const createResponse = async (
return err(responsesCountResult.error as ApiErrorResponseV2);
}
// Limit check completed
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");
}
}
}
return ok(response);
@@ -12,6 +12,7 @@ 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,
@@ -19,6 +20,10 @@ 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(),
@@ -145,8 +150,11 @@ 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);
@@ -183,6 +191,10 @@ 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,6 +1,7 @@
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";
@@ -15,6 +16,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
describe("getWebhooks", () => {
const environmentId = "env1";
const params = {
@@ -81,6 +86,7 @@ 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,6 +1,7 @@
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";
@@ -46,6 +47,8 @@ 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,6 +2,7 @@ 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,
@@ -43,6 +44,8 @@ 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,6 +2,7 @@ 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,
@@ -14,6 +15,8 @@ export const createTeam = async (
teamInput: TTeamInput,
organizationId: string
): Promise<Result<Team, ApiErrorResponseV2>> => {
captureTelemetry("team created");
const { name } = teamInput;
try {
@@ -2,6 +2,7 @@ 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,
@@ -72,6 +73,8 @@ export const createUser = async (
userInput: TUserInput,
organizationId
): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user created");
const { name, email, role, teams, isActive } = userInput;
try {
@@ -147,6 +150,8 @@ 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;
+9 -3
View File
@@ -13,7 +13,7 @@ import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { captureFailedSignup, 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,15 +46,21 @@ const ZCreateUserAction = z.object({
),
});
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
async function verifyTurnstileIfConfigured(
turnstileToken: string | undefined,
email: string,
name: string
): 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");
}
}
@@ -174,7 +180,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);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
const hashedPassword = await hashPassword(parsedInput.password);
const { user, userAlreadyExisted } = await createUserSafely(
@@ -13,12 +13,11 @@ 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 { Checkbox } from "@/modules/ui/components/checkbox";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { createEmailTokenAction } from "../../../auth/actions";
import { PasswordChecks } from "./password-checks";
@@ -50,7 +49,6 @@ interface SignupFormProps {
samlTenant: string;
samlProduct: string;
turnstileSiteKey?: string;
isAdminAccountCreation?: boolean;
}
export const SignupForm = ({
@@ -72,7 +70,6 @@ export const SignupForm = ({
samlTenant,
samlProduct,
turnstileSiteKey,
isAdminAccountCreation = false,
}: SignupFormProps) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
@@ -80,7 +77,6 @@ export const SignupForm = ({
const inviteToken = searchParams?.get("inviteToken");
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState<string>();
const [securityUpdatesConsent, setSecurityUpdatesConsent] = useState(true);
const turnstile = useTurnstile();
@@ -107,47 +103,6 @@ export const SignupForm = ({
throw new Error(t("auth.signup.please_verify_captcha"));
}
if (securityUpdatesConsent && isAdminAccountCreation) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch("https://ee.formbricks.com/api/security-updates/consent", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: data.email,
name: data.name,
consent: true,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error("API call failed");
}
} catch {
toast.error(
<div>
Security list sign up didn&apos;t work, please sign up here manually.{" "}
<a
href="https://app.formbricks.com/s/cmgeq5ao90n4jvl01s9beewcu"
target="_blank"
rel="noopener noreferrer"
className="underline">
Sign up here
</a>
</div>,
{ duration: 10000 }
);
setSecurityUpdatesConsent(false);
}
}
const createUserResponse = await createUserAction({
name: data.name,
email: data.email,
@@ -270,24 +225,6 @@ export const SignupForm = ({
/>
</div>
<PasswordChecks password={form.watch("password")} />
{showLogin && isAdminAccountCreation && (
<div className="my-2 flex items-start space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2">
<Checkbox
id="security-updates-consent"
checked={securityUpdatesConsent}
onCheckedChange={(checked) => setSecurityUpdatesConsent(checked === true)}
className="mt-0.5"
/>
<div className="flex-1 text-left">
<Label htmlFor="security-updates-consent" className="text-sm">
{t("setup.signup.receive_security_updates")}
</Label>
<p className="mt-1 text-xs text-slate-600">
{t("setup.signup.security_updates_consent_description")}
</p>
</div>
</div>
)}
</div>
)}
{isTurnstileConfigured && showLogin && turnstileSiteKey && (
@@ -299,6 +236,7 @@ export const SignupForm = ({
onError={() => {
setTurnstileToken(undefined);
toast.error(t("auth.signup.captcha_failed"));
captureFailedSignup(form.getValues("email"), form.getValues("name"));
}}
/>
)}
@@ -343,18 +281,15 @@ export const SignupForm = ({
/>
)}
<TermsPrivacyLinks termsUrl={termsUrl} privacyUrl={privacyUrl} />
{!isAdminAccountCreation && (
<div className="mt-9 text-center text-xs">
<span className="leading-5 text-slate-500">{t("auth.signup.have_an_account")}</span>
<br />
<Link
href={inviteToken ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login"}
className="font-semibold text-slate-600 underline hover:text-slate-700">
{t("auth.signup.log_in")}
</Link>
</div>
)}
<div className="mt-9 text-center text-xs">
<span className="leading-5 text-slate-500">{t("auth.signup.have_an_account")}</span>
<br />
<Link
href={inviteToken ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login"}
className="font-semibold text-slate-600 underline hover:text-slate-700">
{t("auth.signup.log_in")}
</Link>
</div>
</div>
);
};
@@ -26,6 +26,7 @@ export const TermsPrivacyLinks = ({ termsUrl, privacyUrl }: TermsPrivacyLinksPro
{t("auth.signup.privacy_policy")}
</Link>
)}
<hr className="mx-6 mt-3"></hr>
</div>
);
};
+17 -1
View File
@@ -1,5 +1,6 @@
import posthog from "posthog-js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { verifyTurnstileToken } from "./utils";
import { captureFailedSignup, verifyTurnstileToken } from "./utils";
beforeEach(() => {
global.fetch = vi.fn();
@@ -61,3 +62,18 @@ 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,3 +1,5 @@
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);
@@ -27,3 +29,10 @@ 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,6 +56,9 @@ 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,6 +4,7 @@ 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";
@@ -12,7 +13,6 @@ import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { collectTelemetryData } from "./telemetry";
// Configuration
const CONFIG = {
@@ -246,48 +246,29 @@ 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 {
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 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);
if (!env.ENTERPRISE_LICENSE_KEY) {
try {
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
const responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
},
},
});
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;
@@ -295,7 +276,10 @@ 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(telemetryData),
body: JSON.stringify({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
}),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
@@ -312,6 +296,7 @@ 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);
@@ -356,10 +341,6 @@ 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,
@@ -1,245 +0,0 @@
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);
});
});
@@ -1,630 +0,0 @@
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 } from "@/lib/i18n/utils";
import { addMultiLanguageLabels, extractLanguageCodes, getEnabledLanguages } 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,6 +177,8 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
const [parent] = useAutoAnimate();
const enabledLanguages = getEnabledLanguages(localSurvey.languages);
return (
<div
className={cn(
@@ -300,6 +302,7 @@ 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")}
@@ -36,7 +36,9 @@ export const SignupPage = async () => {
const t = await getTranslate();
return (
<div className="flex flex-col items-center">
<h2 className="mb-2 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<h2 className="mb-6 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<p className="text-sm text-slate-800">{t("setup.signup.this_user_has_all_the_power")}</p>
<hr className="my-6 w-full border-slate-200" />
<SignupForm
webAppUrl={WEBAPP_URL}
termsUrl={TERMS_URL}
@@ -55,7 +57,6 @@ export const SignupPage = async () => {
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
turnstileSiteKey={TURNSTILE_SITE_KEY}
isAdminAccountCreation
/>
</div>
);
@@ -9,11 +9,16 @@ 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(),
}));
@@ -116,6 +121,11 @@ 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,6 +7,7 @@ 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";
@@ -121,6 +122,11 @@ 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, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -30,10 +30,6 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
}
}, [survey, isReadOnly]);
useEffect(() => {
refreshSingleUseId();
}, [refreshSingleUseId]);
return {
singleUseId: isReadOnly ? undefined : singleUseId,
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,
+11 -3
View File
@@ -45,11 +45,11 @@ export const selectSurvey = {
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
code: true,
projectId: true,
alias: true,
},
},
},
@@ -72,7 +72,15 @@ export const selectSurvey = {
},
},
segment: {
include: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
title: true,
description: true,
isPrivate: true,
filters: true,
surveys: {
select: {
id: true,
@@ -3,17 +3,17 @@
import { Project, Response } from "@prisma/client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { validateSurveyPinAction } from "@/modules/survey/link/actions";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
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,11 +23,12 @@ 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) => {
@@ -35,7 +36,6 @@ export const PinScreen = (props: PinScreenProps) => {
surveyId,
project,
publicDomain,
emailVerificationStatus,
singleUseId,
singleUseResponse,
IMPRINT_URL,
@@ -44,11 +44,12 @@ export const PinScreen = (props: PinScreenProps) => {
verifiedEmail,
languageCode,
isEmbed,
locale,
isPreview,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled = false,
responseCount,
styling,
} = props;
const [localPinEntry, setLocalPinEntry] = useState<string>("");
@@ -116,24 +117,24 @@ export const PinScreen = (props: PinScreenProps) => {
}
return (
<LinkSurvey
<SurveyClientWrapper
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
styling={styling}
publicDomain={publicDomain}
verifiedEmail={verifiedEmail}
responseCount={responseCount}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
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}
/>
);
};
@@ -1,160 +1,110 @@
"use client";
import { Project, Response } from "@prisma/client";
import { Project } from "@prisma/client";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TResponseData, TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TProjectStyling } from "@formbricks/types/project";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } 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";
let setQuestionId = (_: string) => {};
let setResponseData = (_: TResponseData) => {};
interface LinkSurveyProps {
interface SurveyClientWrapperProps {
survey: TSurvey;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
emailVerificationStatus?: string;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
styling: TProjectStyling | TSurveyStyling;
publicDomain: string;
responseCount?: number;
verifiedEmail?: string;
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;
locale: string;
isPreview: boolean;
contactId?: string;
recaptchaSiteKey?: string;
isSpamProtectionEnabled?: boolean;
}
export const LinkSurvey = ({
// Module-level functions to allow SurveyInline to control survey state
let setQuestionId = (_: string) => {};
let setResponseData = (_: TResponseData) => {};
export const SurveyClientWrapper = ({
survey,
project,
emailVerificationStatus,
singleUseId,
singleUseResponse,
styling,
publicDomain,
responseCount,
verifiedEmail,
languageCode,
isEmbed,
singleUseId,
singleUseResponseId,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled,
isPreview,
verifiedEmail,
IMPRINT_URL,
PRIVACY_URL,
IS_FORMBRICKS_CLOUD,
locale,
isPreview,
contactId,
recaptchaSiteKey,
isSpamProtectionEnabled = false,
}: LinkSurveyProps) => {
const responseId = singleUseResponse?.id;
}: SurveyClientWrapperProps) => {
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 (survey.welcomeCard.enabled && startAt === "start") return true;
if (welcomeCardEnabled && startAt === "start") return true;
const isValid = surveyQuestions.some((q) => q.id === startAt);
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);
// Clean up invalid startAt from URL to prevent confusion
if (!isValid && globalThis.window !== undefined) {
const url = new URL(globalThis.location.href);
url.searchParams.delete("startAt");
window.history.replaceState({}, "", url.toString());
globalThis.history.replaceState({}, "", url.toString());
}
return isValid;
}, [survey, startAt]);
}, [welcomeCardEnabled, surveyQuestions, startAt]);
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
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
}, []);
// Not in an iframe, enable autofocus on input fields.
// Enable autofocus only when not in iframe
useEffect(() => {
if (window.self === window.top) {
if (globalThis.self === globalThis.top) {
setAutoFocus(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
}, []);
const hiddenFieldsRecord = useMemo<TResponseHiddenFieldValue>(() => {
const fieldsRecord: TResponseHiddenFieldValue = {};
survey.hiddenFields.fieldIds?.forEach((field) => {
// 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;
}
});
if (answer) fieldsRecord[field] = answer;
}
return fieldsRecord;
}, [searchParams, survey.hiddenFields.fieldIds]);
}, [searchParams, JSON.stringify(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({});
@@ -167,8 +117,8 @@ export const LinkSurvey = ({
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}
@@ -180,11 +130,10 @@ export const LinkSurvey = ({
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
styling={determineStyling()}
styling={styling}
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}
@@ -202,7 +151,7 @@ export const LinkSurvey = ({
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={responseId}
singleUseResponseId={singleUseResponseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
@@ -1,22 +1,21 @@
"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 SurveyLinkUsedProps {
interface SurveyCompletedMessageProps {
singleUseMessage: TSurveySingleUse | null;
project?: Pick<Project, "linkSurveyBranding">;
}
export const SurveyLinkUsed = ({ singleUseMessage, project }: SurveyLinkUsedProps) => {
const { t } = useTranslation();
export const SurveyCompletedMessage = async ({ singleUseMessage, project }: SurveyCompletedMessageProps) => {
const t = await getTranslate();
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,6 +1,8 @@
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
IMPRINT_URL,
IS_FORMBRICKS_CLOUD,
@@ -9,16 +11,13 @@ 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;
@@ -27,13 +26,31 @@ interface SurveyRendererProps {
lang?: string;
embed?: string;
preview?: string;
suId?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished"> | undefined;
singleUseResponse?: Pick<Response, "id" | "finished">;
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,
@@ -41,8 +58,11 @@ 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";
@@ -50,27 +70,27 @@ export const renderSurvey = async ({
notFound();
}
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);
// Extract project from pre-fetched context
const { project } = environmentContext;
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 || undefined}
project={project}
/>
);
}
// verify email: Check if the survey requires email verification
// 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
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
@@ -84,40 +104,42 @@ export const renderSurvey = async ({
}
}
// get project
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
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}
/>
);
}
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);
// Compute final styling based on project and survey settings
const styling = computeStyling(project.styling, survey.styling);
const languageCode = getLanguageCode(langParam, isMultiLanguageAllowed, survey);
const publicDomain = getPublicDomain();
if (isSurveyPinProtected) {
// Handle PIN-protected surveys
if (survey.pin) {
return (
<PinScreen
surveyId={survey.id}
styling={styling}
publicDomain={publicDomain}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
IMPRINT_URL={IMPRINT_URL}
@@ -126,35 +148,74 @@ 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 (
<LinkSurvey
<SurveyClientWrapper
survey={survey}
project={project}
styling={styling}
publicDomain={publicDomain}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
responseCount={responseCount}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
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}
/>
);
};
/**
* 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,11 +1,15 @@
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";
@@ -93,18 +97,41 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
}
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 null when contact response not found", async () => {
test("should return undefined 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).toBeNull();
expect(result).toBeUndefined();
});
test("should throw DatabaseError on Prisma error", async () => {
+12 -4
View File
@@ -66,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,
},
},
},
@@ -93,7 +93,15 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
},
},
segment: {
include: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
title: true,
description: true,
isPrivate: true,
filters: true,
surveys: {
select: {
id: true,
@@ -208,7 +216,7 @@ export const getExistingContactResponse = reactCache((surveyId: string, contactI
},
});
return response;
return response ?? undefined;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -0,0 +1,221 @@
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,
});
});
});
@@ -0,0 +1,103 @@
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,16 +19,21 @@ 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
* 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)
*/
export const getBasicSurveyMetadata = async (
surveyId: string,
languageCode = "default"
languageCode = "default",
survey?: Awaited<ReturnType<typeof getSurvey>> | null
): Promise<TBasicSurveyMetadata> => {
const survey = await getSurvey(surveyId);
const surveyData = survey ?? (await getSurvey(surveyId));
// If survey doesn't exist, return default metadata
if (!survey) {
if (!surveyData) {
return {
title: "Survey",
description: "Please complete this survey.",
@@ -37,11 +42,11 @@ export const getBasicSurveyMetadata = async (
};
}
const metadata = survey.metadata;
const welcomeCard = survey.welcomeCard;
const metadata = surveyData.metadata;
const welcomeCard = surveyData.welcomeCard;
const useDefaultLanguageCode =
languageCode === "default" ||
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
surveyData.languages.find((lang) => lang.language.code === languageCode)?.default;
// Determine language code to use for metadata
const langCode = useDefaultLanguageCode ? "default" : languageCode;
@@ -51,10 +56,10 @@ export const getBasicSurveyMetadata = async (
const titleFromWelcome =
welcomeCard?.enabled && welcomeCard.headline
? getTextContent(
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
getLocalizedValue(recallToHeadline(welcomeCard.headline, surveyData, false, langCode), langCode)
) || ""
: undefined;
let title = titleFromMetadata || titleFromWelcome || survey.name;
let title = titleFromMetadata || titleFromWelcome || surveyData.name;
// Set description - priority: custom link metadata > default
const descriptionFromMetadata = metadata?.description
@@ -63,7 +68,7 @@ export const getBasicSurveyMetadata = async (
let description = descriptionFromMetadata || "Please complete this survey.";
// Get OG image from link metadata if available
const { ogImage } = metadata;
const ogImage = metadata?.ogImage;
if (!titleFromMetadata) {
if (IS_FORMBRICKS_CLOUD) {
@@ -74,7 +79,7 @@ export const getBasicSurveyMetadata = async (
return {
title,
description,
survey,
survey: surveyData,
ogImage,
};
};
+10 -10
View File
@@ -1,11 +1,11 @@
import { notFound } from "next/navigation";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
import { getMetadataForLinkSurvey } from "./metadata";
vi.mock("@/modules/survey/link/lib/data", () => ({
getSurveyMetadata: vi.fn(),
getSurveyWithMetadata: vi.fn(),
}));
vi.mock("next/navigation", () => ({
@@ -54,12 +54,12 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
const result = await getMetadataForLinkSurvey(mockSurveyId);
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(getSurveyWithMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined, mockSurvey);
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
expect(result).toEqual({
@@ -98,7 +98,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getBasicSurveyMetadata).mockResolvedValue({
title: mockSurveyName,
description: mockDescription,
@@ -120,7 +120,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
};
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey as any);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey as any);
await getMetadataForLinkSurvey(mockSurveyId);
@@ -135,7 +135,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "draft",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
await getMetadataForLinkSurvey(mockSurveyId);
@@ -150,7 +150,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
twitter: {
title: mockSurveyName,
@@ -192,7 +192,7 @@ describe("getMetadataForLinkSurvey", () => {
status: "published",
} as any;
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
openGraph: {
title: mockSurveyName,
+4 -5
View File
@@ -1,20 +1,19 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
import { getSurveyWithMetadata } 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 getSurveyMetadata(surveyId);
const survey = await getSurveyWithMetadata(surveyId);
if (!survey || survey.type !== "link" || survey.status === "draft") {
if (!survey || survey?.type !== "link" || survey?.status === "draft") {
notFound();
}
// Get enhanced metadata that includes custom link metadata
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode);
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode, survey);
const surveyBrandColor = survey.styling?.brandColor?.light;
// Use the shared function for creating the base metadata but override with custom data
+65 -20
View File
@@ -3,11 +3,14 @@ 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 {
@@ -47,7 +50,29 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
const isPreview = searchParams.preview === "true";
// Use optimized survey data fetcher (includes all necessary data)
/**
* 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)
let survey: TSurvey | null = null;
try {
survey = await getSurveyWithMetadata(params.surveyId);
@@ -56,40 +81,60 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
return notFound();
}
if (!survey) {
return notFound();
}
const suId = searchParams.suId;
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
// Validate single-use ID early (no I/O, just validation)
const isSingleUseSurvey = survey.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey.singleUse?.isEncrypted;
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
// 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} />;
}
singleUseId = validatedSingleUseId;
}
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 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),
]);
// 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: singleUseResponse ?? undefined,
isPreview,
environmentContext,
locale,
isMultiLanguageAllowed,
responseCount,
});
};
@@ -6,7 +6,6 @@ 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";
@@ -48,8 +47,6 @@ export const SurveyCard = ({
const isSurveyCreationDeletionDisabled = isReadOnly;
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const linkHref = useMemo(() => {
return survey.status === "draft"
? `/environments/${environmentId}/surveys/${survey.id}/edit`
@@ -101,7 +98,6 @@ 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 { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -39,7 +39,6 @@ interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurvey;
publicDomain: string;
refreshSingleUseId: () => Promise<string | undefined>;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => void;
@@ -50,7 +49,6 @@ export const SurveyDropDownMenu = ({
environmentId,
survey,
publicDomain,
refreshSingleUseId,
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
@@ -62,26 +60,11 @@ 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]);
// 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 isSingleUseEnabled = survey.singleUse?.enabled ?? false;
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
@@ -100,7 +83,8 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
const copiedLink = copySurveyLink(surveyLink, newSingleUseId);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
@@ -205,31 +189,36 @@ export const SurveyDropDownMenu = ({
<>
<DropdownMenuItem>
<button
className="flex w-full cursor-pointer items-center"
type="button"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
const newId = await refreshSingleUseId();
const previewUrl =
surveyLink + (newId ? `?suId=${newId}&preview=true` : "?preview=true");
const previewUrl = surveyLink + "?preview=true";
window.open(previewUrl, "_blank");
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview_survey")}
</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>
)}
<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>
</>
)}
{!isSurveyCreationDeletionDisabled && (
@@ -7,8 +7,9 @@ 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/components/survey-list";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -154,12 +155,13 @@ export const SurveyFilters = ({
</div>
)}
{(createdBy.length > 0 || status.length > 0 || type.length > 0) && (
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
<Button
size="sm"
onClick={() => {
setSurveyFilters(initialFilters);
localStorage.removeItem("surveyFilters");
setName(""); // Also clear the search input
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}}
className="h-8">
{t("common.clear_filters")}
@@ -10,6 +10,7 @@ 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";
@@ -27,14 +28,6 @@ interface SurveysListProps {
locale: TUserLocale;
}
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};
export const SurveysList = ({
environmentId,
isReadOnly,
@@ -46,14 +39,18 @@ export const SurveysList = ({
}: SurveysListProps) => {
const router = useRouter();
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(true);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [refreshTrigger, setRefreshTrigger] = useState(false);
const { t } = useTranslation();
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
const filters = useMemo(() => getFormattedFilters(surveyFilters, userId), [surveyFilters, userId]);
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 [parent] = useAutoAnimate();
useEffect(() => {
@@ -80,28 +77,30 @@ export const SurveysList = ({
}, [surveyFilters, isFilterInitialized]);
useEffect(() => {
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);
// 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);
}
};
fetchInitialSurveys();
}
}, [environmentId, surveysLimit, filters, isFilterInitialized, refreshTrigger]);
setSurveys(res.data);
setIsFetching(false);
}
};
fetchFilteredSurveys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environmentId, surveysLimit, filters, refreshTrigger, isFilterInitialized]);
const fetchNextPage = useCallback(async () => {
setIsFetching(true);
@@ -0,0 +1,9 @@
import { TSurveyFilters } from "@formbricks/types/surveys/types";
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};
@@ -1,13 +1,37 @@
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 }: EnvironmentIdBaseLayoutProps) => {
export const EnvironmentIdBaseLayout = async ({
children,
environmentId,
session,
user,
organization,
}: 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>
@@ -0,0 +1,56 @@
"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,8 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, 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: {
@@ -26,8 +30,11 @@ 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");
@@ -42,12 +49,20 @@ 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 {
@@ -64,7 +79,8 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
};
loadScript();
}, [containerId, props, renderInline]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (isScriptLoaded) {
@@ -110,6 +110,10 @@ 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) {
@@ -160,7 +164,7 @@ export const ThemeStylingPreviewSurvey = ({
previewMode="desktop"
background={project.styling.cardBackgroundColor?.light}
borderRadius={project.styling.roundness ?? 8}>
<Fragment key={surveyFormKey}>
<Fragment key={surveyKey}>
<SurveyInline
isPreviewMode={true}
survey={{ ...survey, type: "app" }}
@@ -185,7 +189,7 @@ export const ThemeStylingPreviewSurvey = ({
</button>
)}
<div
key={surveyFormKey}
key={surveyKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
isPreviewMode={true}
+2
View File
@@ -108,6 +108,8 @@
"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",
+1
View File
@@ -57,6 +57,7 @@ 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
+4
View File
@@ -186,6 +186,9 @@ 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",
@@ -220,6 +223,7 @@ vi.mock("@/lib/constants", () => ({
"ja-JP",
"zh-Hans-CN",
"es-ES",
"cs-CZ",
],
DEFAULT_LOCALE: "en-US",
BREVO_API_KEY: "mock-brevo-api-key",
@@ -52,6 +52,7 @@ 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 | |
File diff suppressed because it is too large Load Diff
@@ -203,8 +203,9 @@ 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 && localSurvey.languages.length > 0 && offset <= 0;
return localSurvey.showLanguageSwitch && enabledLanguages.length > 1 && offset <= 0;
};
const onFileUpload = async (file: TJsFileUploadParams["file"], params?: TUploadFileConfig) => {
+1
View File
@@ -12,6 +12,7 @@ export const ZUserLocale = z.enum([
"ja-JP",
"zh-Hans-CN",
"es-ES",
"cs-CZ",
]);
export type TUserLocale = z.infer<typeof ZUserLocale>;
+52
View File
@@ -377,6 +377,12 @@ importers:
papaparse:
specifier: 5.5.2
version: 5.5.2
posthog-js:
specifier: 1.240.0
version: 1.240.0
posthog-node:
specifier: 5.9.2
version: 5.9.2
prismjs:
specifier: 1.30.0
version: 1.30.0
@@ -2876,6 +2882,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@posthog/core@1.2.2':
resolution: {integrity: sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg==}
'@preact/preset-vite@2.10.1':
resolution: {integrity: sha512-59lyGBXNfZIr5OOuBUB4/IB8AqF/ULbvYnyItgK/2BJnsGJqaeaJobRVtMp1129obHQuj8oZ/dVxB9inmH8Xig==}
peerDependencies:
@@ -5678,6 +5687,9 @@ packages:
core-js-compat@3.46.0:
resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==}
core-js@3.46.0:
resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -6450,6 +6462,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.7.4:
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
@@ -8231,6 +8246,21 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
posthog-js@1.240.0:
resolution: {integrity: sha512-zZhedVycGracBMWVRvWJMkB2EiB/dUoe/eM+CsFCnda/PN3Se+V7a6CLGuLZKLF9EfHswCxxU/PIxgDrhbAgjQ==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
posthog-node@5.9.2:
resolution: {integrity: sha512-oU7FbFcH5cn40nhP04cBeT67zE76EiGWjKKzDvm6IOm5P83sqM0Ij0wMJQSHp+QI6ZN7MLzb+4xfMPUEZ4q6CA==}
engines: {node: '>=20'}
preact-render-to-string@5.2.6:
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
@@ -9808,6 +9838,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -13088,6 +13121,8 @@ snapshots:
dependencies:
playwright: 1.56.1
'@posthog/core@1.2.2': {}
'@preact/preset-vite@2.10.1(@babel/core@7.28.5)(preact@10.26.6)(vite@6.4.1(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.28.5
@@ -16252,6 +16287,8 @@ snapshots:
dependencies:
browserslist: 4.27.0
core-js@3.46.0: {}
core-util-is@1.0.3: {}
create-require@1.1.1: {}
@@ -17214,6 +17251,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fflate@0.4.8: {}
fflate@0.7.4: {}
file-entry-cache@6.0.1:
@@ -19089,6 +19128,17 @@ snapshots:
dependencies:
xtend: 4.0.2
posthog-js@1.240.0:
dependencies:
core-js: 3.46.0
fflate: 0.4.8
preact: 10.26.6
web-vitals: 4.2.4
posthog-node@5.9.2:
dependencies:
'@posthog/core': 1.2.2
preact-render-to-string@5.2.6(preact@10.26.6):
dependencies:
preact: 10.26.6
@@ -20813,6 +20863,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
web-vitals@4.2.4: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}
+3
View File
@@ -172,6 +172,8 @@
"OIDC_SIGNING_ALGORITHM",
"PASSWORD_RESET_DISABLED",
"PLAYWRIGHT_CI",
"POSTHOG_API_HOST",
"POSTHOG_API_KEY",
"PRIVACY_URL",
"RATE_LIMITING_DISABLED",
"REDIS_URL",
@@ -201,6 +203,7 @@
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"PUBLIC_URL",
"TELEMETRY_DISABLED",
"TURNSTILE_SECRET_KEY",
"TURNSTILE_SITE_KEY",
"RECAPTCHA_SITE_KEY",