mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
Compare commits
2 Commits
feat/proje
...
better-ee-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
256a0ec81a | ||
|
|
58ab40ab8e |
@@ -1,8 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
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 { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 bg-slate-50">
|
<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 />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
@@ -25,11 +25,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvironmentIdBaseLayout
|
<EnvironmentIdBaseLayout>
|
||||||
environmentId={params.environmentId}
|
|
||||||
session={session}
|
|
||||||
user={user}
|
|
||||||
organization={organization}>
|
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
interface PosthogIdentifyProps {
|
|
||||||
session: Session;
|
|
||||||
user: TUser;
|
|
||||||
environmentId?: string;
|
|
||||||
organizationId?: string;
|
|
||||||
organizationName?: string;
|
|
||||||
organizationBilling?: TOrganizationBilling;
|
|
||||||
isPosthogEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PosthogIdentify = ({
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
environmentId,
|
|
||||||
organizationId,
|
|
||||||
organizationName,
|
|
||||||
organizationBilling,
|
|
||||||
isPosthogEnabled,
|
|
||||||
}: PosthogIdentifyProps) => {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPosthogEnabled && session.user && posthog) {
|
|
||||||
posthog.identify(session.user.id, {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
if (environmentId) {
|
|
||||||
posthog.group("environment", environmentId, { name: environmentId });
|
|
||||||
}
|
|
||||||
if (organizationId) {
|
|
||||||
posthog.group("organization", organizationId, {
|
|
||||||
name: organizationName,
|
|
||||||
plan: organizationBilling?.plan,
|
|
||||||
responseLimit: organizationBilling?.limits.monthly.responses,
|
|
||||||
miuLimit: organizationBilling?.limits.monthly.miu,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
posthog,
|
|
||||||
session.user,
|
|
||||||
environmentId,
|
|
||||||
organizationId,
|
|
||||||
organizationName,
|
|
||||||
organizationBilling,
|
|
||||||
user.name,
|
|
||||||
user.email,
|
|
||||||
isPosthogEnabled,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -24,11 +24,7 @@ const EnvLayout = async (props: {
|
|||||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvironmentIdBaseLayout
|
<EnvironmentIdBaseLayout>
|
||||||
environmentId={params.environmentId}
|
|
||||||
session={layoutData.session}
|
|
||||||
user={layoutData.user}
|
|
||||||
organization={layoutData.organization}>
|
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
<EnvironmentContextWrapper
|
<EnvironmentContextWrapper
|
||||||
environment={layoutData.environment}
|
environment={layoutData.environment}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { Suspense } from "react";
|
|
||||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
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 { getUser } from "@/lib/user/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
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";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const AppLayout = async ({ children }) => {
|
const AppLayout = async ({ children }) => {
|
||||||
@@ -21,20 +18,9 @@ const AppLayout = async ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
<Suspense>
|
<IntercomClientWrapper user={user} />
|
||||||
<PostHogPageview
|
<ToasterClient />
|
||||||
posthogEnabled={IS_POSTHOG_CONFIGURED}
|
{children}
|
||||||
postHogApiHost={POSTHOG_API_HOST}
|
|
||||||
postHogApiKey={POSTHOG_API_KEY}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
|
||||||
<>
|
|
||||||
<IntercomClientWrapper user={user} />
|
|
||||||
<ToasterClient />
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
</PHProvider>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Organization } from "@prisma/client";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
|
|
||||||
export const handleBillingLimitsCheck = async (
|
|
||||||
environmentId: string,
|
|
||||||
organizationId: string,
|
|
||||||
organizationBilling: Organization["billing"]
|
|
||||||
): Promise<void> => {
|
|
||||||
if (!IS_FORMBRICKS_CLOUD) return;
|
|
||||||
|
|
||||||
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
|
|
||||||
const responsesLimit = organizationBilling.limits.monthly.responses;
|
|
||||||
|
|
||||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
|
||||||
try {
|
|
||||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
|
||||||
plan: organizationBilling.plan,
|
|
||||||
limits: {
|
|
||||||
projects: null,
|
|
||||||
monthly: {
|
|
||||||
responses: responsesLimit,
|
|
||||||
miu: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Log error but do not throw
|
|
||||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -18,10 +18,6 @@ import {
|
|||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import {
|
|
||||||
capturePosthogEnvironmentEvent,
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
|
||||||
} from "@/lib/posthogServer";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||||
|
|
||||||
@@ -58,19 +54,7 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
|||||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||||
|
|
||||||
if (isLimitReached) {
|
// Limit check completed
|
||||||
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;
|
return isLimitReached;
|
||||||
};
|
};
|
||||||
@@ -111,10 +95,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!environment.appSetupCompleted) {
|
if (!environment.appSetupCompleted) {
|
||||||
await Promise.all([
|
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
||||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
|
||||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check organization subscriptions and response limits
|
// check organization subscriptions and response limits
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
try {
|
try {
|
||||||
const response = await createDisplay(inputValidation.data);
|
const response = await createDisplay(inputValidation.data);
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(response, true),
|
response: responses.successResponse(response, true),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ import { TOrganization } from "@formbricks/types/organizations";
|
|||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||||
import {
|
|
||||||
capturePosthogEnvironmentEvent,
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
|
||||||
} from "@/lib/posthogServer";
|
|
||||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||||
import { getEnvironmentState } from "./environmentState";
|
import { getEnvironmentState } from "./environmentState";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
vi.mock("@/lib/posthogServer");
|
|
||||||
vi.mock("@/lib/cache", () => ({
|
vi.mock("@/lib/cache", () => ({
|
||||||
cache: {
|
cache: {
|
||||||
withCache: vi.fn(),
|
withCache: vi.fn(),
|
||||||
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
||||||
IS_RECAPTCHA_CONFIGURED: true,
|
IS_RECAPTCHA_CONFIGURED: true,
|
||||||
IS_PRODUCTION: true,
|
IS_PRODUCTION: true,
|
||||||
IS_POSTHOG_CONFIGURED: false,
|
|
||||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
|
|||||||
expect(result.data).toEqual(expectedData);
|
expect(result.data).toEqual(expectedData);
|
||||||
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
||||||
expect(prisma.environment.update).not.toHaveBeenCalled();
|
expect(prisma.environment.update).not.toHaveBeenCalled();
|
||||||
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError if environment not found", async () => {
|
test("should throw ResourceNotFoundError if environment not found", async () => {
|
||||||
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
|
|||||||
where: { id: environmentId },
|
where: { id: environmentId },
|
||||||
data: { appSetupCompleted: true },
|
data: { appSetupCompleted: true },
|
||||||
});
|
});
|
||||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
|
|
||||||
expect(result.data).toBeDefined();
|
expect(result.data).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(result.data.surveys).toEqual([]);
|
expect(result.data.surveys).toEqual([]);
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
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 () => {
|
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
|
||||||
@@ -256,21 +237,6 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(result.data.surveys).toEqual(mockSurveys);
|
expect(result.data.surveys).toEqual(mockSurveys);
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
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 () => {
|
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
|
||||||
@@ -313,7 +279,6 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
// Should return surveys even with high count since limit is null (unlimited)
|
// Should return surveys even with high count since limit is null (unlimited)
|
||||||
expect(result.data.surveys).toEqual(mockSurveys);
|
expect(result.data.surveys).toEqual(mockSurveys);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should propagate database update errors", async () => {
|
test("should propagate database update errors", async () => {
|
||||||
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
|
|||||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
|
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 () => {
|
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
|
||||||
const result = await getEnvironmentState(environmentId);
|
const result = await getEnvironmentState(environmentId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createCacheKey } from "@formbricks/cache";
|
import { createCacheKey } from "@formbricks/cache";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||||
import {
|
|
||||||
capturePosthogEnvironmentEvent,
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
|
||||||
} from "@/lib/posthogServer";
|
|
||||||
import { getEnvironmentStateData } from "./data";
|
import { getEnvironmentStateData } from "./data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,13 +28,10 @@ export const getEnvironmentState = async (
|
|||||||
// Handle app setup completion update if needed
|
// Handle app setup completion update if needed
|
||||||
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
||||||
if (!environment.appSetupCompleted) {
|
if (!environment.appSetupCompleted) {
|
||||||
await Promise.all([
|
await prisma.environment.update({
|
||||||
prisma.environment.update({
|
where: { id: environmentId },
|
||||||
where: { id: environmentId },
|
data: { appSetupCompleted: true },
|
||||||
data: { appSetupCompleted: true },
|
});
|
||||||
}),
|
|
||||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check monthly response limits for Formbricks Cloud
|
// Check monthly response limits for Formbricks Cloud
|
||||||
@@ -50,23 +42,7 @@ export const getEnvironmentState = async (
|
|||||||
isMonthlyResponsesLimitReached =
|
isMonthlyResponsesLimitReached =
|
||||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||||
|
|
||||||
// Send plan limits event if needed
|
// Limit check completed
|
||||||
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
|
// Build the response data
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||||
@@ -28,18 +27,10 @@ vi.mock("@/lib/organization/service", () => ({
|
|||||||
getOrganizationByEnvironmentId: vi.fn(),
|
getOrganizationByEnvironmentId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/posthogServer", () => ({
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/response/utils", () => ({
|
vi.mock("@/lib/response/utils", () => ({
|
||||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/telemetry", () => ({
|
|
||||||
captureTelemetry: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/validate", () => ({
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
validateInputs: vi.fn(),
|
validateInputs: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -145,26 +136,6 @@ describe("createResponse", () => {
|
|||||||
await createResponse(mockResponseInput, prisma);
|
await createResponse(mockResponseInput, prisma);
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
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 () => {
|
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||||
@@ -186,20 +157,6 @@ describe("createResponse", () => {
|
|||||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(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", () => {
|
describe("createResponseWithQuotaEvaluation", () => {
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
|
||||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContactByUserId } from "./contact";
|
import { getContactByUserId } from "./contact";
|
||||||
@@ -83,7 +81,6 @@ export const createResponse = async (
|
|||||||
tx: Prisma.TransactionClient
|
tx: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
captureTelemetry("response created");
|
|
||||||
|
|
||||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -121,8 +118,6 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { responses } from "@/app/lib/api/response";
|
|||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
@@ -172,11 +171,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
|
||||||
surveyId: responseData.surveyId,
|
|
||||||
surveyType: survey.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const quotaObj = createQuotaFullObject(quotaFull);
|
const quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
const responseDataWithQuota = {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import { getResponseContact } from "@/lib/response/service";
|
import { getResponseContact } from "@/lib/response/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
@@ -96,9 +95,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
|
|||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
IS_FORMBRICKS_CLOUD: true,
|
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",
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||||
GITHUB_ID: "mock-github-id",
|
GITHUB_ID: "mock-github-id",
|
||||||
@@ -118,10 +114,8 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
vi.mock("@/lib/posthogServer");
|
|
||||||
vi.mock("@/lib/response/service");
|
vi.mock("@/lib/response/service");
|
||||||
vi.mock("@/lib/response/utils");
|
vi.mock("@/lib/response/utils");
|
||||||
vi.mock("@/lib/telemetry");
|
|
||||||
vi.mock("@/lib/utils/validate");
|
vi.mock("@/lib/utils/validate");
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
@@ -234,10 +228,9 @@ describe("Response Lib Tests", () => {
|
|||||||
await createResponse(mockResponseInput, mockTx);
|
await createResponse(mockResponseInput, mockTx);
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should check response limit and not send event if limit not reached", async () => {
|
test("should check response limit if limit not reached", async () => {
|
||||||
const limit = 100;
|
const limit = 100;
|
||||||
const mockOrgWithBilling = {
|
const mockOrgWithBilling = {
|
||||||
...mockOrganization,
|
...mockOrganization,
|
||||||
@@ -251,32 +244,6 @@ describe("Response Lib Tests", () => {
|
|||||||
await createResponse(mockResponseInput, mockTx);
|
await createResponse(mockResponseInput, mockTx);
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
|
||||||
const limit = 100;
|
|
||||||
const mockOrgWithBilling = {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { limits: { monthly: { responses: limit } } },
|
|
||||||
} as any;
|
|
||||||
const posthogError = new Error("Posthog error");
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
|
||||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
|
||||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
|
||||||
|
|
||||||
// Expecting successful response creation despite PostHog error
|
|
||||||
const response = await createResponse(mockResponseInput, mockTx);
|
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
posthogError,
|
|
||||||
"Error sending plan limits reached event to Posthog"
|
|
||||||
);
|
|
||||||
expect(response).toEqual(mockResponse); // Should still return the created response
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
|||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
|
||||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getResponseContact } from "@/lib/response/service";
|
import { getResponseContact } from "@/lib/response/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContactByUserId } from "./contact";
|
import { getContactByUserId } from "./contact";
|
||||||
@@ -93,7 +91,6 @@ export const createResponse = async (
|
|||||||
tx?: Prisma.TransactionClient
|
tx?: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
captureTelemetry("response created");
|
|
||||||
|
|
||||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -131,8 +128,6 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
try {
|
try {
|
||||||
const response = await createDisplay(inputValidation.data);
|
const response = await createDisplay(inputValidation.data);
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
|
||||||
return responses.successResponse(response, true);
|
return responses.successResponse(response, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResourceNotFoundError) {
|
if (error instanceof ResourceNotFoundError) {
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContact } from "./contact";
|
import { getContact } from "./contact";
|
||||||
@@ -49,9 +47,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
vi.mock("@/lib/posthogServer");
|
|
||||||
vi.mock("@/lib/response/utils");
|
vi.mock("@/lib/response/utils");
|
||||||
vi.mock("@/lib/telemetry");
|
|
||||||
vi.mock("@/lib/utils/validate");
|
vi.mock("@/lib/utils/validate");
|
||||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
@@ -166,9 +162,7 @@ describe("createResponse V2", () => {
|
|||||||
...ttc,
|
...ttc,
|
||||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||||
}));
|
}));
|
||||||
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||||
shouldEndSurvey: false,
|
shouldEndSurvey: false,
|
||||||
quotaFull: null,
|
quotaFull: null,
|
||||||
@@ -183,26 +177,6 @@ describe("createResponse V2", () => {
|
|||||||
mockIsFormbricksCloud = true;
|
mockIsFormbricksCloud = true;
|
||||||
await createResponse(mockResponseInput, mockTx);
|
await createResponse(mockResponseInput, mockTx);
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
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 () => {
|
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||||
@@ -225,20 +199,6 @@ describe("createResponse V2", () => {
|
|||||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
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 () => {
|
test("should correctly map prisma tags to response tags", async () => {
|
||||||
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
||||||
const prismaResponseWithTags = {
|
const prismaResponseWithTags = {
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
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 { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContact } from "./contact";
|
import { getContact } from "./contact";
|
||||||
@@ -91,7 +89,6 @@ export const createResponse = async (
|
|||||||
tx?: Prisma.TransactionClient
|
tx?: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
captureTelemetry("response created");
|
|
||||||
|
|
||||||
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -129,8 +126,6 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -148,11 +147,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(environmentId, "response created", {
|
|
||||||
surveyId: responseData.surveyId,
|
|
||||||
surveyType: survey.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const quotaObj = createQuotaFullObject(quotaFull);
|
const quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
const responseDataWithQuota = {
|
||||||
|
|||||||
@@ -218,10 +218,6 @@ export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
|
|||||||
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
|
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
|
||||||
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
|
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_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ export const env = createEnv({
|
|||||||
? z.string().optional()
|
? z.string().optional()
|
||||||
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
|
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
|
||||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||||
POSTHOG_API_HOST: z.string().optional(),
|
|
||||||
POSTHOG_API_KEY: z.string().optional(),
|
|
||||||
PRIVACY_URL: z
|
PRIVACY_URL: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
@@ -103,7 +101,6 @@ export const env = createEnv({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
|
||||||
TERMS_URL: z
|
TERMS_URL: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
@@ -172,8 +169,6 @@ export const env = createEnv({
|
|||||||
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
|
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
|
||||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
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,
|
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||||
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
|
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
|
||||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||||
@@ -206,7 +201,6 @@ export const env = createEnv({
|
|||||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
|
||||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||||
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
|
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from "@formbricks/types/environment";
|
} from "@formbricks/types/environment";
|
||||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||||
import { getOrganizationsByUserId } from "../organization/service";
|
import { getOrganizationsByUserId } from "../organization/service";
|
||||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
|
||||||
import { getUserProjects } from "../project/service";
|
import { getUserProjects } from "../project/service";
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
|
|
||||||
@@ -173,10 +172,6 @@ export const createEnvironment = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
|
|
||||||
environmentType: environment.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
return environment;
|
return environment;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { PostHog } from "posthog-node";
|
|
||||||
import { createCacheKey } from "@formbricks/cache";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations";
|
|
||||||
import { cache } from "@/lib/cache";
|
|
||||||
import { IS_POSTHOG_CONFIGURED, IS_PRODUCTION, POSTHOG_API_HOST, POSTHOG_API_KEY } from "./constants";
|
|
||||||
|
|
||||||
const enabled = IS_PRODUCTION && IS_POSTHOG_CONFIGURED;
|
|
||||||
|
|
||||||
export const capturePosthogEnvironmentEvent = async (
|
|
||||||
environmentId: string,
|
|
||||||
eventName: string,
|
|
||||||
properties: any = {}
|
|
||||||
) => {
|
|
||||||
if (!enabled || typeof POSTHOG_API_HOST !== "string" || typeof POSTHOG_API_KEY !== "string") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const client = new PostHog(POSTHOG_API_KEY, {
|
|
||||||
host: POSTHOG_API_HOST,
|
|
||||||
});
|
|
||||||
client.capture({
|
|
||||||
// workaround with a static string as exaplained in PostHog docs: https://posthog.com/docs/product-analytics/group-analytics
|
|
||||||
distinctId: "environmentEvents",
|
|
||||||
event: eventName,
|
|
||||||
groups: { environment: environmentId },
|
|
||||||
properties,
|
|
||||||
});
|
|
||||||
await client.shutdown();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "error sending posthog event");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendPlanLimitsReachedEventToPosthogWeekly = async (
|
|
||||||
environmentId: string,
|
|
||||||
billing: {
|
|
||||||
plan: TOrganizationBillingPlan;
|
|
||||||
limits: TOrganizationBillingPlanLimits;
|
|
||||||
}
|
|
||||||
) =>
|
|
||||||
await cache.withCache(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", {
|
|
||||||
...billing,
|
|
||||||
});
|
|
||||||
return "success";
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "error sending plan limits reached event to posthog weekly");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
createCacheKey.custom("analytics", environmentId, `plan_limits_${billing.plan}`),
|
|
||||||
60 * 60 * 24 * 7 * 1000 // 7 days in milliseconds
|
|
||||||
);
|
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
subscribeOrganizationMembersToSurveyResponses,
|
subscribeOrganizationMembersToSurveyResponses,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { evaluateLogic } from "@/lib/surveyLogic/utils";
|
import { evaluateLogic } from "@/lib/surveyLogic/utils";
|
||||||
import {
|
import {
|
||||||
mockActionClass,
|
mockActionClass,
|
||||||
@@ -44,11 +43,6 @@ vi.mock("@/lib/organization/service", () => ({
|
|||||||
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
|
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock posthogServer
|
|
||||||
vi.mock("@/lib/posthogServer", () => ({
|
|
||||||
capturePosthogEnvironmentEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock actionClass service
|
// Mock actionClass service
|
||||||
vi.mock("@/lib/actionClass/service", () => ({
|
vi.mock("@/lib/actionClass/service", () => ({
|
||||||
getActionClasses: vi.fn(),
|
getActionClasses: vi.fn(),
|
||||||
@@ -646,7 +640,6 @@ describe("Tests for createSurvey", () => {
|
|||||||
expect(prisma.survey.create).toHaveBeenCalled();
|
expect(prisma.survey.create).toHaveBeenCalled();
|
||||||
expect(result.name).toEqual(mockSurveyOutput.name);
|
expect(result.name).toEqual(mockSurveyOutput.name);
|
||||||
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
|
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
|
||||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a private segment for app surveys", async () => {
|
test("creates a private segment for app surveys", async () => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { getActionClasses } from "../actionClass/service";
|
import { getActionClasses } from "../actionClass/service";
|
||||||
import { ITEMS_PER_PAGE } from "../constants";
|
import { ITEMS_PER_PAGE } from "../constants";
|
||||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||||
|
|
||||||
@@ -673,11 +672,6 @@ export const createSurvey = async (
|
|||||||
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
|
||||||
surveyId: survey.id,
|
|
||||||
surveyType: survey.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
return transformedSurvey;
|
return transformedSurvey;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
/* We use this telemetry service to better understand how Formbricks is being used
|
|
||||||
and how we can improve it. All data including the IP address is collected anonymously
|
|
||||||
and we cannot trace anything back to you or your customers. If you still want to
|
|
||||||
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { IS_PRODUCTION } from "./constants";
|
|
||||||
import { env } from "./env";
|
|
||||||
|
|
||||||
const crypto = require("crypto");
|
|
||||||
|
|
||||||
// We are using the hashed CRON_SECRET as the distinct identifier for the instance for telemetry.
|
|
||||||
// The hash cannot be traced back to the original value or the instance itself.
|
|
||||||
// This is to ensure that the telemetry data is anonymous but still unique to the instance.
|
|
||||||
const getTelemetryId = (): string => {
|
|
||||||
return crypto.createHash("sha256").update(env.CRON_SECRET).digest("hex");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const captureTelemetry = async (eventName: string, properties = {}) => {
|
|
||||||
if (env.TELEMETRY_DISABLED !== "1" && IS_PRODUCTION) {
|
|
||||||
try {
|
|
||||||
await fetch("https://telemetry.formbricks.com/capture/", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret
|
|
||||||
event: eventName,
|
|
||||||
properties: {
|
|
||||||
distinct_id: getTelemetryId(),
|
|
||||||
...properties,
|
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "error sending telemetry");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { Prisma, Response } from "@prisma/client";
|
import { Prisma, Response } from "@prisma/client";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
|
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
|
||||||
import {
|
import {
|
||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
@@ -51,8 +48,6 @@ export const createResponse = async (
|
|||||||
responseInput: TResponseInput,
|
responseInput: TResponseInput,
|
||||||
tx?: Prisma.TransactionClient
|
tx?: Prisma.TransactionClient
|
||||||
): Promise<Result<Response, ApiErrorResponseV2>> => {
|
): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||||
captureTelemetry("response created");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
surveyId,
|
surveyId,
|
||||||
displayId,
|
displayId,
|
||||||
@@ -126,7 +121,6 @@ export const createResponse = async (
|
|||||||
if (!billing.ok) {
|
if (!billing.ok) {
|
||||||
return err(billing.error as ApiErrorResponseV2);
|
return err(billing.error as ApiErrorResponseV2);
|
||||||
}
|
}
|
||||||
const billingData = billing.data;
|
|
||||||
|
|
||||||
const prismaClient = tx ?? prisma;
|
const prismaClient = tx ?? prisma;
|
||||||
|
|
||||||
@@ -140,26 +134,7 @@ export const createResponse = async (
|
|||||||
return err(responsesCountResult.error as ApiErrorResponseV2);
|
return err(responsesCountResult.error as ApiErrorResponseV2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesCount = responsesCountResult.data;
|
// Limit check completed
|
||||||
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);
|
return ok(response);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { err, ok } from "@formbricks/types/error-handlers";
|
import { err, ok } from "@formbricks/types/error-handlers";
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import {
|
import {
|
||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationBilling,
|
getOrganizationBilling,
|
||||||
@@ -20,10 +19,6 @@ import {
|
|||||||
} from "@/modules/api/v2/management/responses/lib/organization";
|
} from "@/modules/api/v2/management/responses/lib/organization";
|
||||||
import { createResponse, getResponses } from "../response";
|
import { createResponse, getResponses } from "../response";
|
||||||
|
|
||||||
vi.mock("@/lib/posthogServer", () => ({
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
|
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
|
||||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||||
getOrganizationBilling: vi.fn(),
|
getOrganizationBilling: vi.fn(),
|
||||||
@@ -150,11 +145,8 @@ describe("Response Lib", () => {
|
|||||||
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||||
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockImplementation(() => Promise.resolve(""));
|
|
||||||
|
|
||||||
const result = await createResponse(environmentId, responseInput);
|
const result = await createResponse(environmentId, responseInput);
|
||||||
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
expect(result.data).toEqual(response);
|
expect(result.data).toEqual(response);
|
||||||
@@ -191,10 +183,6 @@ describe("Response Lib", () => {
|
|||||||
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||||
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(
|
|
||||||
new Error("Error sending plan limits")
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await createResponse(environmentId, responseInput);
|
const result = await createResponse(environmentId, responseInput);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { WebhookSource } from "@prisma/client";
|
import { WebhookSource } from "@prisma/client";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||||
import { createWebhook, getWebhooks } from "../webhook";
|
import { createWebhook, getWebhooks } from "../webhook";
|
||||||
|
|
||||||
@@ -16,10 +15,6 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/telemetry", () => ({
|
|
||||||
captureTelemetry: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("getWebhooks", () => {
|
describe("getWebhooks", () => {
|
||||||
const environmentId = "env1";
|
const environmentId = "env1";
|
||||||
const params = {
|
const params = {
|
||||||
@@ -86,7 +81,6 @@ describe("createWebhook", () => {
|
|||||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
||||||
|
|
||||||
const result = await createWebhook(inputWebhook);
|
const result = await createWebhook(inputWebhook);
|
||||||
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
|
|
||||||
expect(prisma.webhook.create).toHaveBeenCalled();
|
expect(prisma.webhook.create).toHaveBeenCalled();
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Prisma, Webhook } from "@prisma/client";
|
import { Prisma, Webhook } from "@prisma/client";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
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 { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
@@ -47,8 +46,6 @@ export const getWebhooks = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||||
captureTelemetry("webhook_created");
|
|
||||||
|
|
||||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { ProjectTeam } from "@prisma/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
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 { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
|
||||||
import {
|
import {
|
||||||
TGetProjectTeamsFilter,
|
TGetProjectTeamsFilter,
|
||||||
@@ -44,8 +43,6 @@ export const getProjectTeams = async (
|
|||||||
export const createProjectTeam = async (
|
export const createProjectTeam = async (
|
||||||
teamInput: TProjectTeamInput
|
teamInput: TProjectTeamInput
|
||||||
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
|
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
|
||||||
captureTelemetry("project team created");
|
|
||||||
|
|
||||||
const { teamId, projectId, permission } = teamInput;
|
const { teamId, projectId, permission } = teamInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import "server-only";
|
|||||||
import { Team } from "@prisma/client";
|
import { Team } from "@prisma/client";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
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 { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
|
||||||
import {
|
import {
|
||||||
TGetTeamsFilter,
|
TGetTeamsFilter,
|
||||||
@@ -15,8 +14,6 @@ export const createTeam = async (
|
|||||||
teamInput: TTeamInput,
|
teamInput: TTeamInput,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<Result<Team, ApiErrorResponseV2>> => {
|
): Promise<Result<Team, ApiErrorResponseV2>> => {
|
||||||
captureTelemetry("team created");
|
|
||||||
|
|
||||||
const { name } = teamInput;
|
const { name } = teamInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TUser } from "@formbricks/database/zod/users";
|
import { TUser } from "@formbricks/database/zod/users";
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
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 { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
|
||||||
import {
|
import {
|
||||||
TGetUsersFilter,
|
TGetUsersFilter,
|
||||||
@@ -73,8 +72,6 @@ export const createUser = async (
|
|||||||
userInput: TUserInput,
|
userInput: TUserInput,
|
||||||
organizationId
|
organizationId
|
||||||
): Promise<Result<TUser, ApiErrorResponseV2>> => {
|
): Promise<Result<TUser, ApiErrorResponseV2>> => {
|
||||||
captureTelemetry("user created");
|
|
||||||
|
|
||||||
const { name, email, role, teams, isActive } = userInput;
|
const { name, email, role, teams, isActive } = userInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -150,8 +147,6 @@ export const updateUser = async (
|
|||||||
userInput: TUserInputPatch,
|
userInput: TUserInputPatch,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<Result<TUser, ApiErrorResponseV2>> => {
|
): Promise<Result<TUser, ApiErrorResponseV2>> => {
|
||||||
captureTelemetry("user updated");
|
|
||||||
|
|
||||||
const { name, email, role, teams, isActive } = userInput;
|
const { name, email, role, teams, isActive } = userInput;
|
||||||
let existingTeams: string[] = [];
|
let existingTeams: string[] = [];
|
||||||
let newTeams;
|
let newTeams;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|||||||
import { createUser, updateUser } from "@/modules/auth/lib/user";
|
import { createUser, updateUser } from "@/modules/auth/lib/user";
|
||||||
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
|
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
|
||||||
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
|
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
|
||||||
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
|
import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
|
||||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
@@ -46,21 +46,15 @@ const ZCreateUserAction = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function verifyTurnstileIfConfigured(
|
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
||||||
turnstileToken: string | undefined,
|
|
||||||
email: string,
|
|
||||||
name: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!IS_TURNSTILE_CONFIGURED) return;
|
if (!IS_TURNSTILE_CONFIGURED) return;
|
||||||
|
|
||||||
if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
|
if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
|
||||||
captureFailedSignup(email, name);
|
|
||||||
throw new UnknownError("Server configuration error");
|
throw new UnknownError("Server configuration error");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
|
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
|
||||||
if (!isHuman) {
|
if (!isHuman) {
|
||||||
captureFailedSignup(email, name);
|
|
||||||
throw new UnknownError("reCAPTCHA verification failed");
|
throw new UnknownError("reCAPTCHA verification failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +174,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
|||||||
"user",
|
"user",
|
||||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||||
await applyIPRateLimit(rateLimitConfigs.auth.signup);
|
await applyIPRateLimit(rateLimitConfigs.auth.signup);
|
||||||
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
|
await verifyTurnstileIfConfigured(parsedInput.turnstileToken);
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(parsedInput.password);
|
const hashedPassword = await hashPassword(parsedInput.password);
|
||||||
const { user, userAlreadyExisted } = await createUserSafely(
|
const { user, userAlreadyExisted } = await createUserSafely(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { createUserAction } from "@/modules/auth/signup/actions";
|
import { createUserAction } from "@/modules/auth/signup/actions";
|
||||||
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
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 { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||||
@@ -236,7 +235,6 @@ export const SignupForm = ({
|
|||||||
onError={() => {
|
onError={() => {
|
||||||
setTurnstileToken(undefined);
|
setTurnstileToken(undefined);
|
||||||
toast.error(t("auth.signup.captcha_failed"));
|
toast.error(t("auth.signup.captcha_failed"));
|
||||||
captureFailedSignup(form.getValues("email"), form.getValues("name"));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import posthog from "posthog-js";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { captureFailedSignup, verifyTurnstileToken } from "./utils";
|
import { verifyTurnstileToken } from "./utils";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
@@ -62,18 +61,3 @@ describe("verifyTurnstileToken", () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("captureFailedSignup", () => {
|
|
||||||
test("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => {
|
|
||||||
const captureSpy = vi.spyOn(posthog, "capture");
|
|
||||||
const email = "test@example.com";
|
|
||||||
const name = "Test User";
|
|
||||||
|
|
||||||
captureFailedSignup(email, name);
|
|
||||||
|
|
||||||
expect(captureSpy).toHaveBeenCalledWith("TELEMETRY_FAILED_SIGNUP", {
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import posthog from "posthog-js";
|
|
||||||
|
|
||||||
export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
|
export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
@@ -29,10 +27,3 @@ export const verifyTurnstileToken = async (secretKey: string, token: string): Pr
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const captureFailedSignup = (email: string, name: string) => {
|
|
||||||
posthog.capture("TELEMETRY_FAILED_SIGNUP", {
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -56,9 +56,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
ITEMS_PER_PAGE: 2,
|
ITEMS_PER_PAGE: 2,
|
||||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
|
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
IS_POSTHOG_CONFIGURED: false,
|
|
||||||
POSTHOG_API_HOST: "test-posthog-host",
|
|
||||||
POSTHOG_API_KEY: "test-posthog-key",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const environmentId = "cm123456789012345678901237";
|
const environmentId = "cm123456789012345678901237";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import fetch from "node-fetch";
|
|||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createCacheKey } from "@formbricks/cache";
|
import { createCacheKey } from "@formbricks/cache";
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
TEnterpriseLicenseDetails,
|
TEnterpriseLicenseDetails,
|
||||||
TEnterpriseLicenseFeatures,
|
TEnterpriseLicenseFeatures,
|
||||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||||
|
import { collectTelemetryData } from "./telemetry";
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
@@ -246,29 +246,48 @@ const handleInitialFailure = async (currentTime: Date) => {
|
|||||||
|
|
||||||
// API functions
|
// API functions
|
||||||
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
|
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
|
||||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
|
||||||
|
|
||||||
// Skip license checks during build time
|
// Skip license checks during build time
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
|
// 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") {
|
if (process.env.NEXT_PHASE === "phase-production-build") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let telemetryData;
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
telemetryData = await collectTelemetryData(env.ENTERPRISE_LICENSE_KEY || null);
|
||||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
} catch (telemetryError) {
|
||||||
// first millisecond of next year => current year is fully included
|
logger.warn({ error: telemetryError }, "Telemetry collection failed, proceeding with minimal data");
|
||||||
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
|
telemetryData = {
|
||||||
|
licenseKey: env.ENTERPRISE_LICENSE_KEY || null,
|
||||||
|
usage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const responseCount = await prisma.response.count({
|
if (!env.ENTERPRISE_LICENSE_KEY) {
|
||||||
where: {
|
try {
|
||||||
createdAt: {
|
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||||
gte: startOfYear,
|
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||||
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 proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||||
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||||
|
|
||||||
@@ -276,10 +295,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
|||||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
||||||
|
|
||||||
const res = await fetch(CONFIG.API.ENDPOINT, {
|
const res = await fetch(CONFIG.API.ENDPOINT, {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(telemetryData),
|
||||||
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
|
||||||
usage: { responseCount },
|
|
||||||
}),
|
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
agent,
|
agent,
|
||||||
@@ -296,7 +312,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
|||||||
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
||||||
trackApiError(error);
|
trackApiError(error);
|
||||||
|
|
||||||
// Retry on specific status codes
|
|
||||||
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
|
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
|
||||||
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
|
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
|
||||||
return fetchLicenseFromServerInternal(retryCount + 1);
|
return fetchLicenseFromServerInternal(retryCount + 1);
|
||||||
@@ -341,6 +356,10 @@ export const getEnterpriseLicense = reactCache(
|
|||||||
validateConfig();
|
validateConfig();
|
||||||
|
|
||||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
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 {
|
return {
|
||||||
active: false,
|
active: false,
|
||||||
features: null,
|
features: null,
|
||||||
|
|||||||
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal file
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { collectTelemetryData } from "./telemetry";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
organization: { count: vi.fn(), findFirst: vi.fn() },
|
||||||
|
user: { count: vi.fn(), findFirst: vi.fn() },
|
||||||
|
team: { count: vi.fn() },
|
||||||
|
project: { count: vi.fn() },
|
||||||
|
survey: { count: vi.fn(), findFirst: vi.fn() },
|
||||||
|
contact: { count: vi.fn() },
|
||||||
|
segment: { count: vi.fn() },
|
||||||
|
display: { count: vi.fn() },
|
||||||
|
response: { count: vi.fn() },
|
||||||
|
surveyLanguage: { findFirst: vi.fn() },
|
||||||
|
surveyAttributeFilter: { findFirst: vi.fn() },
|
||||||
|
apiKey: { findFirst: vi.fn() },
|
||||||
|
teamUser: { findFirst: vi.fn() },
|
||||||
|
surveyQuota: { findFirst: vi.fn() },
|
||||||
|
webhook: { findFirst: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@formbricks/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/constants", () => ({
|
||||||
|
IS_FORMBRICKS_CLOUD: false,
|
||||||
|
IS_STORAGE_CONFIGURED: true,
|
||||||
|
IS_RECAPTCHA_CONFIGURED: true,
|
||||||
|
AUDIT_LOG_ENABLED: true,
|
||||||
|
GOOGLE_OAUTH_ENABLED: true,
|
||||||
|
GITHUB_OAUTH_ENABLED: false,
|
||||||
|
AZURE_OAUTH_ENABLED: false,
|
||||||
|
OIDC_OAUTH_ENABLED: false,
|
||||||
|
SAML_OAUTH_ENABLED: false,
|
||||||
|
AIRTABLE_CLIENT_ID: "test-airtable-id",
|
||||||
|
SLACK_CLIENT_ID: "test-slack-id",
|
||||||
|
SLACK_CLIENT_SECRET: "test-slack-secret",
|
||||||
|
NOTION_OAUTH_CLIENT_ID: "test-notion-id",
|
||||||
|
NOTION_OAUTH_CLIENT_SECRET: "test-notion-secret",
|
||||||
|
GOOGLE_SHEETS_CLIENT_ID: "test-sheets-id",
|
||||||
|
GOOGLE_SHEETS_CLIENT_SECRET: "test-sheets-secret",
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Telemetry Collection", () => {
|
||||||
|
const mockLicenseKey = "test-license-key-123";
|
||||||
|
const mockOrganizationId = "org-123";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||||
|
id: mockOrganizationId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectTelemetryData", () => {
|
||||||
|
test("should return null usage for cloud instances", async () => {
|
||||||
|
// Mock IS_FORMBRICKS_CLOUD as true for this test
|
||||||
|
const actualConstants = await vi.importActual("@/lib/constants");
|
||||||
|
vi.doMock("@/lib/constants", () => ({
|
||||||
|
...(actualConstants as Record<string, unknown>),
|
||||||
|
IS_FORMBRICKS_CLOUD: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-import to get the new mock
|
||||||
|
const { collectTelemetryData: collectWithCloud } = await import("./telemetry");
|
||||||
|
const result = await collectWithCloud(mockLicenseKey);
|
||||||
|
|
||||||
|
expect(result.licenseKey).toBe(mockLicenseKey);
|
||||||
|
expect(result.usage).toBeNull();
|
||||||
|
|
||||||
|
// Reset mock
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should collect basic counts successfully", async () => {
|
||||||
|
vi.mocked(prisma.organization.count).mockResolvedValue(1);
|
||||||
|
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||||
|
vi.mocked(prisma.team.count).mockResolvedValue(2);
|
||||||
|
vi.mocked(prisma.project.count).mockResolvedValue(3);
|
||||||
|
vi.mocked(prisma.survey.count).mockResolvedValue(10);
|
||||||
|
vi.mocked(prisma.contact.count).mockResolvedValue(100);
|
||||||
|
vi.mocked(prisma.segment.count).mockResolvedValue(5);
|
||||||
|
vi.mocked(prisma.display.count).mockResolvedValue(500);
|
||||||
|
vi.mocked(prisma.response.count).mockResolvedValue(1000);
|
||||||
|
|
||||||
|
const result = await collectTelemetryData(mockLicenseKey);
|
||||||
|
|
||||||
|
expect(result.usage).toBeTruthy();
|
||||||
|
if (result.usage) {
|
||||||
|
expect(result.usage.organizationCount).toBe(1);
|
||||||
|
expect(result.usage.memberCount).toBe(5);
|
||||||
|
expect(result.usage.teamCount).toBe(2);
|
||||||
|
expect(result.usage.projectCount).toBe(3);
|
||||||
|
expect(result.usage.surveyCount).toBe(10);
|
||||||
|
expect(result.usage.contactCount).toBe(100);
|
||||||
|
expect(result.usage.segmentCount).toBe(5);
|
||||||
|
expect(result.usage.surveyDisplayCount).toBe(500);
|
||||||
|
expect(result.usage.responseCountAllTime).toBe(1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle query timeouts gracefully", async () => {
|
||||||
|
// Simulate slow query that times out (but resolve it eventually)
|
||||||
|
let resolveOrgCount: (value: number) => void;
|
||||||
|
const orgCountPromise = new Promise<number>((resolve) => {
|
||||||
|
resolveOrgCount = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
|
||||||
|
|
||||||
|
// Mock other queries to return quickly
|
||||||
|
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||||
|
vi.mocked(prisma.team.count).mockResolvedValue(2);
|
||||||
|
vi.mocked(prisma.project.count).mockResolvedValue(3);
|
||||||
|
vi.mocked(prisma.survey.count).mockResolvedValue(10);
|
||||||
|
vi.mocked(prisma.contact.count).mockResolvedValue(100);
|
||||||
|
vi.mocked(prisma.segment.count).mockResolvedValue(5);
|
||||||
|
vi.mocked(prisma.display.count).mockResolvedValue(500);
|
||||||
|
vi.mocked(prisma.response.count).mockResolvedValue(1000);
|
||||||
|
|
||||||
|
// Mock batch 2 queries
|
||||||
|
vi.mocked(prisma.survey.findFirst).mockResolvedValue({ id: "survey-1" } as any);
|
||||||
|
|
||||||
|
// Start collection
|
||||||
|
const resultPromise = collectTelemetryData(mockLicenseKey);
|
||||||
|
|
||||||
|
// Advance timers past the 2s query timeout
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
|
||||||
|
// Resolve the slow query after timeout
|
||||||
|
resolveOrgCount!(1);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
// Should still return result, but with null values for timed-out queries
|
||||||
|
expect(result.usage).toBeTruthy();
|
||||||
|
expect(result.usage?.organizationCount).toBeNull();
|
||||||
|
// Other queries should still work
|
||||||
|
expect(result.usage?.memberCount).toBe(5);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
test("should handle database errors gracefully", async () => {
|
||||||
|
const dbError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||||
|
code: "P2002",
|
||||||
|
clientVersion: "5.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.organization.count).mockRejectedValue(dbError);
|
||||||
|
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||||
|
|
||||||
|
const result = await collectTelemetryData(mockLicenseKey);
|
||||||
|
|
||||||
|
// Should continue despite errors
|
||||||
|
expect(result.usage).toBeTruthy();
|
||||||
|
expect(result.usage?.organizationCount).toBeNull();
|
||||||
|
expect(result.usage?.memberCount).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should detect feature usage correctly", async () => {
|
||||||
|
// Mock feature detection queries
|
||||||
|
vi.mocked(prisma.surveyLanguage.findFirst).mockResolvedValue({ languageId: "en" } as any);
|
||||||
|
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({
|
||||||
|
id: "user-2",
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
} as any);
|
||||||
|
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue({ id: "key-1" } as any);
|
||||||
|
|
||||||
|
// Mock all count queries to return 0 to avoid complexity
|
||||||
|
vi.mocked(prisma.organization.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.team.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.project.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.survey.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.contact.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.segment.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.display.count).mockResolvedValue(0);
|
||||||
|
vi.mocked(prisma.response.count).mockResolvedValue(0);
|
||||||
|
|
||||||
|
const result = await collectTelemetryData(mockLicenseKey);
|
||||||
|
|
||||||
|
expect(result.usage?.featureUsage).toBeTruthy();
|
||||||
|
if (result.usage?.featureUsage) {
|
||||||
|
expect(result.usage.featureUsage.multiLanguageSurveys).toBe(true);
|
||||||
|
expect(result.usage.featureUsage.twoFA).toBe(true);
|
||||||
|
expect(result.usage.featureUsage.apiKeys).toBe(true);
|
||||||
|
expect(result.usage.featureUsage.sso).toBe(true); // From constants
|
||||||
|
expect(result.usage.featureUsage.fileUpload).toBe(true); // From constants
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should generate instance ID when no organization exists", async () => {
|
||||||
|
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await collectTelemetryData(mockLicenseKey);
|
||||||
|
|
||||||
|
expect(result.usage).toBeTruthy();
|
||||||
|
expect(result.usage?.instanceId).toBeTruthy();
|
||||||
|
expect(typeof result.usage?.instanceId).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle total timeout gracefully", async () => {
|
||||||
|
let resolveOrgFind: (value: any) => void;
|
||||||
|
const orgFindPromise = new Promise<any>((resolve) => {
|
||||||
|
resolveOrgFind = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.organization.findFirst).mockImplementation(() => orgFindPromise as any);
|
||||||
|
|
||||||
|
let resolveOrgCount: (value: number) => void;
|
||||||
|
const orgCountPromise = new Promise<number>((resolve) => {
|
||||||
|
resolveOrgCount = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
|
||||||
|
|
||||||
|
// Start collection
|
||||||
|
const resultPromise = collectTelemetryData(mockLicenseKey);
|
||||||
|
|
||||||
|
// Advance timers past the 15s total timeout
|
||||||
|
await vi.advanceTimersByTimeAsync(16000);
|
||||||
|
|
||||||
|
resolveOrgFind!({ id: mockOrganizationId, createdAt: new Date() });
|
||||||
|
resolveOrgCount!(1);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
// Should return usage object (may be empty or partial)
|
||||||
|
expect(result.usage).toBeTruthy();
|
||||||
|
}, 20000);
|
||||||
|
});
|
||||||
|
});
|
||||||
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal file
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
import "server-only";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import {
|
||||||
|
AIRTABLE_CLIENT_ID,
|
||||||
|
AUDIT_LOG_ENABLED,
|
||||||
|
AZURE_OAUTH_ENABLED,
|
||||||
|
GITHUB_OAUTH_ENABLED,
|
||||||
|
GOOGLE_OAUTH_ENABLED,
|
||||||
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
|
IS_FORMBRICKS_CLOUD,
|
||||||
|
IS_RECAPTCHA_CONFIGURED,
|
||||||
|
IS_STORAGE_CONFIGURED,
|
||||||
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
|
NOTION_OAUTH_CLIENT_SECRET,
|
||||||
|
OIDC_OAUTH_ENABLED,
|
||||||
|
SAML_OAUTH_ENABLED,
|
||||||
|
SLACK_CLIENT_ID,
|
||||||
|
SLACK_CLIENT_SECRET,
|
||||||
|
} from "@/lib/constants";
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
QUERY_TIMEOUT_MS: 2000,
|
||||||
|
BATCH_TIMEOUT_MS: 5000,
|
||||||
|
TOTAL_TIMEOUT_MS: 15000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TelemetryUsage = {
|
||||||
|
instanceId: string;
|
||||||
|
organizationCount: number | null;
|
||||||
|
memberCount: number | null;
|
||||||
|
teamCount: number | null;
|
||||||
|
projectCount: number | null;
|
||||||
|
surveyCount: number | null;
|
||||||
|
activeSurveyCount: number | null;
|
||||||
|
completedSurveyCount: number | null;
|
||||||
|
responseCountAllTime: number | null;
|
||||||
|
responseCountLast30d: number | null;
|
||||||
|
surveyDisplayCount: number | null;
|
||||||
|
contactCount: number | null;
|
||||||
|
segmentCount: number | null;
|
||||||
|
featureUsage: {
|
||||||
|
multiLanguageSurveys: boolean | null;
|
||||||
|
advancedTargeting: boolean | null;
|
||||||
|
sso: boolean | null;
|
||||||
|
saml: boolean | null;
|
||||||
|
twoFA: boolean | null;
|
||||||
|
apiKeys: boolean | null;
|
||||||
|
teamRoles: boolean | null;
|
||||||
|
auditLogs: boolean | null;
|
||||||
|
whitelabel: boolean | null;
|
||||||
|
removeBranding: boolean | null;
|
||||||
|
fileUpload: boolean | null;
|
||||||
|
spamProtection: boolean | null;
|
||||||
|
quotas: boolean | null;
|
||||||
|
};
|
||||||
|
activeIntegrations: {
|
||||||
|
airtable: boolean | null;
|
||||||
|
slack: boolean | null;
|
||||||
|
notion: boolean | null;
|
||||||
|
googleSheets: boolean | null;
|
||||||
|
zapier: boolean | null;
|
||||||
|
make: boolean | null;
|
||||||
|
n8n: boolean | null;
|
||||||
|
webhook: boolean | null;
|
||||||
|
};
|
||||||
|
temporal: {
|
||||||
|
instanceCreatedAt: string | null;
|
||||||
|
newestSurveyDate: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelemetryData = {
|
||||||
|
licenseKey: string | null;
|
||||||
|
usage: TelemetryUsage | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T | null>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.warn({ timeoutMs }, "Query timeout exceeded");
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeQuery = async <T>(
|
||||||
|
queryFn: () => Promise<T>,
|
||||||
|
queryName: string,
|
||||||
|
batchNumber: number
|
||||||
|
): Promise<T | null> => {
|
||||||
|
try {
|
||||||
|
const result = await withTimeout(queryFn(), CONFIG.QUERY_TIMEOUT_MS);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
queryName,
|
||||||
|
batchNumber,
|
||||||
|
},
|
||||||
|
`Telemetry query failed: ${queryName}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInstanceId = async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const firstOrg = await withTimeout(
|
||||||
|
prisma.organization.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
CONFIG.QUERY_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!firstOrg) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.createHash("sha256").update(firstOrg.id).digest("hex").substring(0, 32);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to get instance ID, using random UUID");
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectBatch1 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||||
|
const queries = [
|
||||||
|
{
|
||||||
|
name: "organizationCount",
|
||||||
|
fn: () => prisma.organization.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memberCount",
|
||||||
|
fn: () => prisma.user.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "teamCount",
|
||||||
|
fn: () => prisma.team.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "projectCount",
|
||||||
|
fn: () => prisma.project.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "surveyCount",
|
||||||
|
fn: () => prisma.survey.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contactCount",
|
||||||
|
fn: () => prisma.contact.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "segmentCount",
|
||||||
|
fn: () => prisma.segment.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "surveyDisplayCount",
|
||||||
|
fn: () => prisma.display.count(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "responseCountAllTime",
|
||||||
|
fn: () => prisma.response.count(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 1)));
|
||||||
|
|
||||||
|
const batchResult: Partial<TelemetryUsage> = {};
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
const key = queries[index].name as keyof TelemetryUsage;
|
||||||
|
if (result.status === "fulfilled" && result.value !== null) {
|
||||||
|
(batchResult as Record<string, unknown>)[key] = result.value;
|
||||||
|
} else {
|
||||||
|
(batchResult as Record<string, unknown>)[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return batchResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectBatch2 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
{
|
||||||
|
name: "activeSurveyCount",
|
||||||
|
fn: () => prisma.survey.count({ where: { status: "inProgress" } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completedSurveyCount",
|
||||||
|
fn: () => prisma.survey.count({ where: { status: "completed" } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "responseCountLast30d",
|
||||||
|
fn: () =>
|
||||||
|
prisma.response.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: thirtyDaysAgo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 2)));
|
||||||
|
|
||||||
|
const batchResult: Partial<TelemetryUsage> = {};
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
const key = queries[index].name as keyof TelemetryUsage;
|
||||||
|
if (result.status === "fulfilled" && result.value !== null) {
|
||||||
|
(batchResult as Record<string, unknown>)[key] = result.value;
|
||||||
|
} else {
|
||||||
|
(batchResult as Record<string, unknown>)[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return batchResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectBatch3 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||||
|
const queries = [
|
||||||
|
{
|
||||||
|
name: "multiLanguageSurveys",
|
||||||
|
fn: async () => {
|
||||||
|
const result = await prisma.surveyLanguage.findFirst({ select: { languageId: true } });
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "advancedTargeting",
|
||||||
|
fn: async () => {
|
||||||
|
const [hasFilters, hasSegments] = await Promise.all([
|
||||||
|
prisma.surveyAttributeFilter.findFirst({ select: { id: true } }),
|
||||||
|
prisma.survey.findFirst({ where: { segmentId: { not: null } } }),
|
||||||
|
]);
|
||||||
|
return hasFilters !== null || hasSegments !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "twoFA",
|
||||||
|
fn: async () => {
|
||||||
|
const result = await prisma.user.findFirst({
|
||||||
|
where: { twoFactorEnabled: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "apiKeys",
|
||||||
|
fn: async () => {
|
||||||
|
const result = await prisma.apiKey.findFirst({ select: { id: true } });
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "teamRoles",
|
||||||
|
fn: async () => {
|
||||||
|
const result = await prisma.teamUser.findFirst({ select: { teamId: true } });
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitelabel",
|
||||||
|
fn: async () => {
|
||||||
|
const organizations = await prisma.organization.findMany({
|
||||||
|
select: { whitelabel: true },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
return organizations.some((org) => {
|
||||||
|
const whitelabel = org.whitelabel as Record<string, unknown> | null;
|
||||||
|
return whitelabel !== null && typeof whitelabel === "object" && Object.keys(whitelabel).length > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removeBranding",
|
||||||
|
fn: async () => {
|
||||||
|
const organizations = await prisma.organization.findMany({
|
||||||
|
select: { billing: true },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
return organizations.some((org) => {
|
||||||
|
const billing = org.billing as { plan?: string; removeBranding?: boolean } | null;
|
||||||
|
return billing?.removeBranding === true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quotas",
|
||||||
|
fn: async () => {
|
||||||
|
const result = await prisma.surveyQuota.findFirst({ select: { id: true } });
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 3)));
|
||||||
|
|
||||||
|
const batchResult: Partial<TelemetryUsage> = {
|
||||||
|
featureUsage: {
|
||||||
|
multiLanguageSurveys: null,
|
||||||
|
advancedTargeting: null,
|
||||||
|
sso: null,
|
||||||
|
saml: null,
|
||||||
|
twoFA: null,
|
||||||
|
apiKeys: null,
|
||||||
|
teamRoles: null,
|
||||||
|
auditLogs: null,
|
||||||
|
whitelabel: null,
|
||||||
|
removeBranding: null,
|
||||||
|
fileUpload: null,
|
||||||
|
spamProtection: null,
|
||||||
|
quotas: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const featureMap: Record<string, keyof TelemetryUsage["featureUsage"]> = {
|
||||||
|
multiLanguageSurveys: "multiLanguageSurveys",
|
||||||
|
advancedTargeting: "advancedTargeting",
|
||||||
|
twoFA: "twoFA",
|
||||||
|
apiKeys: "apiKeys",
|
||||||
|
teamRoles: "teamRoles",
|
||||||
|
whitelabel: "whitelabel",
|
||||||
|
removeBranding: "removeBranding",
|
||||||
|
quotas: "quotas",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
const queryName = queries[index].name;
|
||||||
|
const featureKey = featureMap[queryName];
|
||||||
|
if (featureKey && batchResult.featureUsage) {
|
||||||
|
if (result.status === "fulfilled" && result.value !== null) {
|
||||||
|
batchResult.featureUsage[featureKey] = result.value;
|
||||||
|
} else {
|
||||||
|
batchResult.featureUsage[featureKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchResult.featureUsage) {
|
||||||
|
batchResult.featureUsage.sso =
|
||||||
|
GOOGLE_OAUTH_ENABLED ||
|
||||||
|
GITHUB_OAUTH_ENABLED ||
|
||||||
|
AZURE_OAUTH_ENABLED ||
|
||||||
|
OIDC_OAUTH_ENABLED ||
|
||||||
|
SAML_OAUTH_ENABLED;
|
||||||
|
batchResult.featureUsage.saml = SAML_OAUTH_ENABLED;
|
||||||
|
batchResult.featureUsage.auditLogs = AUDIT_LOG_ENABLED;
|
||||||
|
batchResult.featureUsage.fileUpload = IS_STORAGE_CONFIGURED;
|
||||||
|
batchResult.featureUsage.spamProtection = IS_RECAPTCHA_CONFIGURED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return batchResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectBatch4 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||||
|
const booleanQueries = [
|
||||||
|
{
|
||||||
|
name: "zapier",
|
||||||
|
fn: async (): Promise<boolean> => {
|
||||||
|
const result = await prisma.webhook.findFirst({
|
||||||
|
where: { source: "zapier" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "make",
|
||||||
|
fn: async (): Promise<boolean> => {
|
||||||
|
const result = await prisma.webhook.findFirst({
|
||||||
|
where: { source: "make" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "n8n",
|
||||||
|
fn: async (): Promise<boolean> => {
|
||||||
|
const result = await prisma.webhook.findFirst({
|
||||||
|
where: { source: "n8n" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webhook",
|
||||||
|
fn: async (): Promise<boolean> => {
|
||||||
|
const result = await prisma.webhook.findFirst({
|
||||||
|
where: { source: "user" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return result !== null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stringQueries = [
|
||||||
|
{
|
||||||
|
name: "instanceCreatedAt",
|
||||||
|
fn: async (): Promise<string | null> => {
|
||||||
|
const result = await prisma.user.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
return result?.createdAt.toISOString() ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "newestSurveyDate",
|
||||||
|
fn: async (): Promise<string | null> => {
|
||||||
|
const result = await prisma.survey.findFirst({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
return result?.createdAt.toISOString() ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const booleanResults = await Promise.allSettled(
|
||||||
|
booleanQueries.map((query) => safeQuery(query.fn, query.name, 4))
|
||||||
|
);
|
||||||
|
const stringResults = await Promise.allSettled(
|
||||||
|
stringQueries.map((query) => safeQuery(query.fn, query.name, 4))
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchResult: Partial<TelemetryUsage> = {
|
||||||
|
activeIntegrations: {
|
||||||
|
airtable: null,
|
||||||
|
slack: null,
|
||||||
|
notion: null,
|
||||||
|
googleSheets: null,
|
||||||
|
zapier: null,
|
||||||
|
make: null,
|
||||||
|
n8n: null,
|
||||||
|
webhook: null,
|
||||||
|
},
|
||||||
|
temporal: {
|
||||||
|
instanceCreatedAt: null,
|
||||||
|
newestSurveyDate: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const integrationMap: Record<string, keyof TelemetryUsage["activeIntegrations"]> = {
|
||||||
|
zapier: "zapier",
|
||||||
|
make: "make",
|
||||||
|
n8n: "n8n",
|
||||||
|
webhook: "webhook",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [index, result] of booleanResults.entries()) {
|
||||||
|
const queryName = booleanQueries[index].name;
|
||||||
|
const integrationKey = integrationMap[queryName];
|
||||||
|
if (integrationKey && batchResult.activeIntegrations) {
|
||||||
|
if (result.status === "fulfilled" && result.value !== null) {
|
||||||
|
batchResult.activeIntegrations[integrationKey] = result.value;
|
||||||
|
} else {
|
||||||
|
batchResult.activeIntegrations[integrationKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, result] of stringResults.entries()) {
|
||||||
|
const queryName = stringQueries[index].name;
|
||||||
|
if (batchResult.temporal && (queryName === "instanceCreatedAt" || queryName === "newestSurveyDate")) {
|
||||||
|
if (result.status === "fulfilled" && result.value !== null) {
|
||||||
|
batchResult.temporal[queryName] = result.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchResult.activeIntegrations) {
|
||||||
|
batchResult.activeIntegrations.airtable = !!AIRTABLE_CLIENT_ID;
|
||||||
|
batchResult.activeIntegrations.slack = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
|
||||||
|
batchResult.activeIntegrations.notion = !!(NOTION_OAUTH_CLIENT_ID && NOTION_OAUTH_CLIENT_SECRET);
|
||||||
|
batchResult.activeIntegrations.googleSheets = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
return batchResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectTelemetryData = async (licenseKey: string | null): Promise<TelemetryData> => {
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return {
|
||||||
|
licenseKey,
|
||||||
|
usage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instanceId = await getInstanceId();
|
||||||
|
|
||||||
|
const batchPromises = [
|
||||||
|
Promise.race([
|
||||||
|
collectBatch1(),
|
||||||
|
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.warn("Batch 1 timeout");
|
||||||
|
resolve({});
|
||||||
|
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Promise.race([
|
||||||
|
collectBatch2(),
|
||||||
|
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.warn("Batch 2 timeout");
|
||||||
|
resolve({});
|
||||||
|
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Promise.race([
|
||||||
|
collectBatch3(),
|
||||||
|
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.warn("Batch 3 timeout");
|
||||||
|
resolve({});
|
||||||
|
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Promise.race([
|
||||||
|
collectBatch4(),
|
||||||
|
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.warn("Batch 4 timeout");
|
||||||
|
resolve({});
|
||||||
|
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const batchResults = await Promise.race([
|
||||||
|
Promise.all(batchPromises),
|
||||||
|
new Promise<Partial<TelemetryUsage>[]>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.warn("Total telemetry collection timeout");
|
||||||
|
resolve([{}, {}, {}, {}]);
|
||||||
|
}, CONFIG.TOTAL_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const usage: TelemetryUsage = {
|
||||||
|
instanceId,
|
||||||
|
organizationCount: null,
|
||||||
|
memberCount: null,
|
||||||
|
teamCount: null,
|
||||||
|
projectCount: null,
|
||||||
|
surveyCount: null,
|
||||||
|
activeSurveyCount: null,
|
||||||
|
completedSurveyCount: null,
|
||||||
|
responseCountAllTime: null,
|
||||||
|
responseCountLast30d: null,
|
||||||
|
surveyDisplayCount: null,
|
||||||
|
contactCount: null,
|
||||||
|
segmentCount: null,
|
||||||
|
featureUsage: {
|
||||||
|
multiLanguageSurveys: null,
|
||||||
|
advancedTargeting: null,
|
||||||
|
sso: null,
|
||||||
|
saml: null,
|
||||||
|
twoFA: null,
|
||||||
|
apiKeys: null,
|
||||||
|
teamRoles: null,
|
||||||
|
auditLogs: null,
|
||||||
|
whitelabel: null,
|
||||||
|
removeBranding: null,
|
||||||
|
fileUpload: null,
|
||||||
|
spamProtection: null,
|
||||||
|
quotas: null,
|
||||||
|
},
|
||||||
|
activeIntegrations: {
|
||||||
|
airtable: null,
|
||||||
|
slack: null,
|
||||||
|
notion: null,
|
||||||
|
googleSheets: null,
|
||||||
|
zapier: null,
|
||||||
|
make: null,
|
||||||
|
n8n: null,
|
||||||
|
webhook: null,
|
||||||
|
},
|
||||||
|
temporal: {
|
||||||
|
instanceCreatedAt: null,
|
||||||
|
newestSurveyDate: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const batchResult of batchResults) {
|
||||||
|
Object.assign(usage, batchResult);
|
||||||
|
if (batchResult.featureUsage) {
|
||||||
|
Object.assign(usage.featureUsage, batchResult.featureUsage);
|
||||||
|
}
|
||||||
|
if (batchResult.activeIntegrations) {
|
||||||
|
Object.assign(usage.activeIntegrations, batchResult.activeIntegrations);
|
||||||
|
}
|
||||||
|
if (batchResult.temporal) {
|
||||||
|
Object.assign(usage.temporal, batchResult.temporal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info({ duration, instanceId }, "Telemetry collection completed");
|
||||||
|
|
||||||
|
return {
|
||||||
|
licenseKey,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, duration: Date.now() - startTime }, "Telemetry collection failed completely");
|
||||||
|
return {
|
||||||
|
licenseKey,
|
||||||
|
usage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,16 +9,11 @@ import {
|
|||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
subscribeOrganizationMembersToSurveyResponses,
|
subscribeOrganizationMembersToSurveyResponses,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||||
import { selectSurvey } from "@/modules/survey/lib/survey";
|
import { selectSurvey } from "@/modules/survey/lib/survey";
|
||||||
import { createSurvey, handleTriggerUpdates } from "./survey";
|
import { createSurvey, handleTriggerUpdates } from "./survey";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/posthogServer", () => ({
|
|
||||||
capturePosthogEnvironmentEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/survey/utils", () => ({
|
vi.mock("@/lib/survey/utils", () => ({
|
||||||
checkForInvalidImagesInQuestions: vi.fn(),
|
checkForInvalidImagesInQuestions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -121,11 +116,6 @@ describe("survey module", () => {
|
|||||||
"user-123",
|
"user-123",
|
||||||
"org-123"
|
"org-123"
|
||||||
);
|
);
|
||||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(
|
|
||||||
environmentId,
|
|
||||||
"survey created",
|
|
||||||
expect.objectContaining({ surveyId: "survey-123" })
|
|
||||||
);
|
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result.id).toBe("survey-123");
|
expect(result.id).toBe("survey-123");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
subscribeOrganizationMembersToSurveyResponses,
|
subscribeOrganizationMembersToSurveyResponses,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||||
@@ -122,11 +121,6 @@ export const createSurvey = async (
|
|||||||
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
|
||||||
surveyId: survey.id,
|
|
||||||
surveyType: survey.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
return transformedSurvey;
|
return transformedSurvey;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -1,37 +1,13 @@
|
|||||||
import { Session } from "next-auth";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
|
||||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
interface EnvironmentIdBaseLayoutProps {
|
interface EnvironmentIdBaseLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
environmentId: string;
|
|
||||||
session: Session;
|
|
||||||
user: TUser;
|
|
||||||
organization: TOrganization;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentIdBaseLayout = async ({
|
export const EnvironmentIdBaseLayout = async ({ children }: EnvironmentIdBaseLayoutProps) => {
|
||||||
children,
|
|
||||||
environmentId,
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
organization,
|
|
||||||
}: EnvironmentIdBaseLayoutProps) => {
|
|
||||||
return (
|
return (
|
||||||
<ResponseFilterProvider>
|
<ResponseFilterProvider>
|
||||||
<PosthogIdentify
|
|
||||||
session={session}
|
|
||||||
user={user}
|
|
||||||
environmentId={environmentId}
|
|
||||||
organizationId={organization.id}
|
|
||||||
organizationName={organization.name}
|
|
||||||
organizationBilling={organization.billing}
|
|
||||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
|
||||||
/>
|
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</ResponseFilterProvider>
|
</ResponseFilterProvider>
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
|
||||||
import posthog from "posthog-js";
|
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
|
||||||
import React, { type JSX, useEffect } from "react";
|
|
||||||
|
|
||||||
interface PostHogPageviewProps {
|
|
||||||
posthogEnabled: boolean;
|
|
||||||
postHogApiHost?: string;
|
|
||||||
postHogApiKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PostHogPageview = ({
|
|
||||||
posthogEnabled,
|
|
||||||
postHogApiHost,
|
|
||||||
postHogApiKey,
|
|
||||||
}: PostHogPageviewProps): JSX.Element => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!posthogEnabled) return;
|
|
||||||
try {
|
|
||||||
if (!postHogApiHost) {
|
|
||||||
throw new Error("Posthog API host is required");
|
|
||||||
}
|
|
||||||
if (!postHogApiKey) {
|
|
||||||
throw new Error("Posthog key is required");
|
|
||||||
}
|
|
||||||
posthog.init(postHogApiKey, { api_host: postHogApiHost });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize PostHog:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!posthogEnabled) return;
|
|
||||||
let url = window.origin + pathname;
|
|
||||||
if (searchParams?.toString()) {
|
|
||||||
url += `?${searchParams.toString()}`;
|
|
||||||
}
|
|
||||||
posthog.capture("$pageview", { $current_url: url });
|
|
||||||
}, [pathname, searchParams, posthogEnabled]);
|
|
||||||
|
|
||||||
return <></>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PHPProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
posthogEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PHProvider = ({ children, posthogEnabled }: PHPProviderProps) => {
|
|
||||||
return posthogEnabled ? <PostHogProvider client={posthog}>{children}</PostHogProvider> : children;
|
|
||||||
};
|
|
||||||
@@ -108,8 +108,6 @@
|
|||||||
"nodemailer": "7.0.9",
|
"nodemailer": "7.0.9",
|
||||||
"otplib": "12.0.1",
|
"otplib": "12.0.1",
|
||||||
"papaparse": "5.5.2",
|
"papaparse": "5.5.2",
|
||||||
"posthog-js": "1.240.0",
|
|
||||||
"posthog-node": "5.9.2",
|
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"qr-code-styling": "1.9.2",
|
"qr-code-styling": "1.9.2",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export default defineConfig({
|
|||||||
"**/actions.ts", // Server actions (plural)
|
"**/actions.ts", // Server actions (plural)
|
||||||
"**/action.ts", // Server actions (singular)
|
"**/action.ts", // Server actions (singular)
|
||||||
"lib/env.ts", // Environment configuration
|
"lib/env.ts", // Environment configuration
|
||||||
"lib/posthogServer.ts", // PostHog server integration
|
|
||||||
"**/cache.ts", // Cache files
|
"**/cache.ts", // Cache files
|
||||||
"**/cache/**", // Cache directories
|
"**/cache/**", // Cache directories
|
||||||
|
|
||||||
|
|||||||
@@ -186,9 +186,6 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
|
|||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
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",
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||||
GITHUB_ID: "mock-github-id",
|
GITHUB_ID: "mock-github-id",
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ These variables are present inside your machine's docker-compose file. Restart t
|
|||||||
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
|
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
|
||||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||||
| STRIPE_WEBHOOK_SECRET | Webhook secret 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_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 | |
|
| 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 | |
|
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||||
|
|||||||
@@ -172,8 +172,6 @@
|
|||||||
"OIDC_SIGNING_ALGORITHM",
|
"OIDC_SIGNING_ALGORITHM",
|
||||||
"PASSWORD_RESET_DISABLED",
|
"PASSWORD_RESET_DISABLED",
|
||||||
"PLAYWRIGHT_CI",
|
"PLAYWRIGHT_CI",
|
||||||
"POSTHOG_API_HOST",
|
|
||||||
"POSTHOG_API_KEY",
|
|
||||||
"PRIVACY_URL",
|
"PRIVACY_URL",
|
||||||
"RATE_LIMITING_DISABLED",
|
"RATE_LIMITING_DISABLED",
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
@@ -203,7 +201,6 @@
|
|||||||
"SURVEYS_PACKAGE_MODE",
|
"SURVEYS_PACKAGE_MODE",
|
||||||
"SURVEYS_PACKAGE_BUILD",
|
"SURVEYS_PACKAGE_BUILD",
|
||||||
"PUBLIC_URL",
|
"PUBLIC_URL",
|
||||||
"TELEMETRY_DISABLED",
|
|
||||||
"TURNSTILE_SECRET_KEY",
|
"TURNSTILE_SECRET_KEY",
|
||||||
"TURNSTILE_SITE_KEY",
|
"TURNSTILE_SITE_KEY",
|
||||||
"RECAPTCHA_SITE_KEY",
|
"RECAPTCHA_SITE_KEY",
|
||||||
|
|||||||
Reference in New Issue
Block a user