diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts index 1091d43527..efa27cc18b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts @@ -125,7 +125,7 @@ export const updateSurveyAction = authenticatedActionClient const { followUps } = parsedInput; if (parsedInput.recaptcha?.enabled) { - await checkSpamProtectionPermission(); + await checkSpamProtectionPermission(organizationId); } if (followUps?.length) { diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.test.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.test.ts index 4ee5fbe4d0..aa3e635782 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.test.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.test.ts @@ -8,7 +8,6 @@ import { capturePosthogEnvironmentEvent, sendPlanLimitsReachedEventToPosthogWeekly, } from "@/lib/posthogServer"; -import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; @@ -48,6 +47,8 @@ vi.mock("./survey"); vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: true, // Default to false, override in specific tests RECAPTCHA_SITE_KEY: "mock_recaptcha_site_key", + RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key", + IS_RECAPTCHA_CONFIGURED: true, IS_PRODUCTION: true, IS_POSTHOG_CONFIGURED: false, ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key", @@ -255,7 +256,6 @@ describe("getEnvironmentState", () => { vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject); vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys); vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses); - vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); // Default to false vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit }); @@ -267,6 +267,7 @@ describe("getEnvironmentState", () => { const result = await getEnvironmentState(environmentId); const expectedData: TJsEnvironmentState["data"] = { + recaptchaSiteKey: "mock_recaptcha_site_key", surveys: [mockSurveys[0]], // Only app, inProgress survey actionClasses: mockActionClasses, project: mockProject, @@ -279,7 +280,6 @@ describe("getEnvironmentState", () => { expect(getProjectForEnvironmentState).toHaveBeenCalledWith(environmentId); expect(getSurveysForEnvironmentState).toHaveBeenCalledWith(environmentId); expect(getActionClassesForEnvironmentState).toHaveBeenCalledWith(environmentId); - expect(getIsSpamProtectionEnabled).toHaveBeenCalled(); expect(prisma.environment.update).not.toHaveBeenCalled(); expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled(); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalled(); // Not cloud @@ -358,22 +358,12 @@ describe("getEnvironmentState", () => { ); }); - test("should include recaptchaSiteKey if spam protection is enabled", async () => { - vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); - + test("should include recaptchaSiteKey if recaptcha variables are set", async () => { const result = await getEnvironmentState(environmentId); expect(result.data.recaptchaSiteKey).toBe("mock_recaptcha_site_key"); }); - test("should not include recaptchaSiteKey if spam protection is disabled", async () => { - vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); - - const result = await getEnvironmentState(environmentId); - - expect(result.data.recaptchaSiteKey).toBeUndefined(); - }); - test("should filter surveys correctly (only app type and inProgress status)", async () => { const result = await getEnvironmentState(environmentId); expect(result.data.surveys).toHaveLength(1); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts index d437920b86..702b9ab22d 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts @@ -1,6 +1,6 @@ import { actionClassCache } from "@/lib/actionClass/cache"; import { cache } from "@/lib/cache"; -import { IS_FORMBRICKS_CLOUD, RECAPTCHA_SITE_KEY } from "@/lib/constants"; +import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants"; import { environmentCache } from "@/lib/environment/cache"; import { getEnvironment } from "@/lib/environment/service"; import { organizationCache } from "@/lib/organization/cache"; @@ -14,7 +14,6 @@ import { } from "@/lib/posthogServer"; import { projectCache } from "@/lib/project/cache"; import { surveyCache } from "@/lib/survey/cache"; -import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { ResourceNotFoundError } from "@formbricks/types/errors"; @@ -99,7 +98,6 @@ export const getEnvironmentState = async ( getSurveysForEnvironmentState(environmentId), getActionClassesForEnvironmentState(environmentId), ]); - const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(); const filteredSurveys = surveys.filter( (survey) => survey.type === "app" && survey.status === "inProgress" @@ -109,7 +107,7 @@ export const getEnvironmentState = async ( surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [], actionClasses, project: project, - ...(isSpamProtectionEnabled ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}), + ...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}), }; return { diff --git a/apps/web/app/api/v1/management/surveys/lib/utils.ts b/apps/web/app/api/v1/management/surveys/lib/utils.ts index 0777189512..9aff1cc306 100644 --- a/apps/web/app/api/v1/management/surveys/lib/utils.ts +++ b/apps/web/app/api/v1/management/surveys/lib/utils.ts @@ -9,7 +9,7 @@ export const checkFeaturePermissions = async ( organization: TOrganization ): Promise => { if (surveyData.recaptcha?.enabled) { - const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(); + const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.billing.plan); if (!isSpamProtectionEnabled) { return responses.forbiddenResponse("Spam protection is not enabled for this organization"); } diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.test.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.test.ts new file mode 100644 index 0000000000..ce31d9e6c1 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.test.ts @@ -0,0 +1,81 @@ +import { Organization } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { getOrganizationBillingByEnvironmentId } from "./organization"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { + findFirst: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); +vi.mock("@/lib/cache", () => ({ + cache: (fn: any) => fn, +})); +vi.mock("@/lib/organization/cache", () => ({ + organizationCache: { + tag: { + byEnvironmentId: (id: string) => `tag-${id}`, + }, + }, +})); +vi.mock("react", () => ({ + cache: (fn: any) => fn, +})); + +describe("getOrganizationBillingByEnvironmentId", () => { + const environmentId = "env-123"; + const mockBillingData: Organization["billing"] = { + limits: { + monthly: { miu: 0, responses: 0 }, + projects: 3, + }, + period: "monthly", + periodStart: new Date(), + plan: "scale", + stripeCustomerId: "mock-stripe-customer-id", + }; + + test("returns billing when organization is found", async () => { + vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: mockBillingData }); + const result = await getOrganizationBillingByEnvironmentId(environmentId); + expect(result).toEqual(mockBillingData); + expect(prisma.organization.findFirst).toHaveBeenCalledWith({ + where: { + projects: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + select: { + billing: true, + }, + }); + }); + + test("returns null when organization is not found", async () => { + vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce(null); + const result = await getOrganizationBillingByEnvironmentId(environmentId); + expect(result).toBeNull(); + }); + + test("logs error and returns null on exception", async () => { + const error = new Error("db error"); + vi.mocked(prisma.organization.findFirst).mockRejectedValueOnce(error); + const result = await getOrganizationBillingByEnvironmentId(environmentId); + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith(error, "Failed to get organization billing by environment ID"); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.ts new file mode 100644 index 0000000000..13df6cfcec --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.ts @@ -0,0 +1,45 @@ +import { cache } from "@/lib/cache"; +import { organizationCache } from "@/lib/organization/cache"; +import { Organization } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; + +export const getOrganizationBillingByEnvironmentId = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + try { + const organization = await prisma.organization.findFirst({ + where: { + projects: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + select: { + billing: true, + }, + }); + + if (!organization) { + return null; + } + + return organization.billing; + } catch (error) { + logger.error(error, "Failed to get organization billing by environment ID"); + return null; + } + }, + [`api-v2-client-getOrganizationBillingByEnvironmentId-${environmentId}`], + { + tags: [organizationCache.tag.byEnvironmentId(environmentId)], + } + )() +); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.test.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.test.ts index 9efb2bea19..8360de0724 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.test.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.test.ts @@ -1,9 +1,11 @@ +import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization"; import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha"; import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { responses } from "@/app/lib/api/response"; import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; -import { describe, expect, test, vi } from "vitest"; +import { Organization } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "@formbricks/logger"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -14,6 +16,7 @@ vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({ vi.mock("@/app/lib/api/response", () => ({ responses: { badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })), + notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })), }, })); @@ -21,6 +24,10 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsSpamProtectionEnabled: vi.fn(), })); +vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({ + getOrganizationBillingByEnvironmentId: vi.fn(), +})); + vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), @@ -76,7 +83,22 @@ const mockResponseInput: TResponseInputV2 = { meta: {}, }; +const mockBillingData: Organization["billing"] = { + limits: { + monthly: { miu: 0, responses: 0 }, + projects: 3, + }, + period: "monthly", + periodStart: new Date(), + plan: "scale", + stripeCustomerId: "mock-stripe-customer-id", +}; + describe("checkSurveyValidity", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + test("should return badRequestResponse if survey environmentId does not match", async () => { const survey = { ...mockSurvey, environmentId: "env-2" }; const result = await checkSurveyValidity(survey, "env-1", mockResponseInput); @@ -98,18 +120,6 @@ describe("checkSurveyValidity", () => { expect(result).toBeNull(); }); - test("should return null if recaptcha is enabled but spam protection is disabled", async () => { - const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; - vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); - vi.mocked(verifyRecaptchaToken).mockResolvedValue(true); - const result = await checkSurveyValidity(survey, "env-1", { - ...mockResponseInput, - recaptchaToken: "test-token", - }); - expect(result).toBeNull(); - expect(logger.error).toHaveBeenCalledWith("Spam protection is not enabled for this organization"); - }); - test("should return badRequestResponse if recaptcha enabled, spam protection enabled, but token is missing", async () => { const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); @@ -127,11 +137,40 @@ describe("checkSurveyValidity", () => { ); }); + test("should return not found response if billing data is not found", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(null); + + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + recaptchaToken: "test-token", + }); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(404); + expect(responses.notFoundResponse).toHaveBeenCalledWith("Organization", null); + expect(getOrganizationBillingByEnvironmentId).toHaveBeenCalledWith("env-1"); + }); + + test("should return null if recaptcha is enabled but spam protection is disabled", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); + vi.mocked(verifyRecaptchaToken).mockResolvedValue(true); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData); + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + recaptchaToken: "test-token", + }); + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith("Spam protection is not enabled for this organization"); + }); + test("should return badRequestResponse if recaptcha verification fails", async () => { const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" }; vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); vi.mocked(verifyRecaptchaToken).mockResolvedValue(false); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData); const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken); expect(result).toBeInstanceOf(Response); @@ -149,6 +188,7 @@ describe("checkSurveyValidity", () => { const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" }; vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); vi.mocked(verifyRecaptchaToken).mockResolvedValue(true); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData); const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken); expect(result).toBeNull(); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts index eb2f577b89..63d0ad6e5e 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts @@ -1,3 +1,4 @@ +import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization"; import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { responses } from "@/app/lib/api/response"; @@ -34,7 +35,13 @@ export const checkSurveyValidity = async ( true ); } - const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(); + const billing = await getOrganizationBillingByEnvironmentId(environmentId); + + if (!billing) { + return responses.notFoundResponse("Organization", null); + } + + const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(billing.plan); if (!isSpamProtectionEnabled) { logger.error("Spam protection is not enabled for this organization"); diff --git a/apps/web/modules/ee/license-check/lib/utils.ts b/apps/web/modules/ee/license-check/lib/utils.ts index d3012c2760..2536f179b8 100644 --- a/apps/web/modules/ee/license-check/lib/utils.ts +++ b/apps/web/modules/ee/license-check/lib/utils.ts @@ -391,13 +391,18 @@ export const getIsSamlSsoEnabled = async (): Promise => { return licenseFeatures.sso && licenseFeatures.saml; }; -export const getIsSpamProtectionEnabled = async (): Promise => { +export const getIsSpamProtectionEnabled = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { if (!IS_RECAPTCHA_CONFIGURED) return false; if (E2E_TESTING) { const previousResult = await fetchLicenseForE2ETesting(); return previousResult?.features ? previousResult.features.spamProtection : false; } + if (IS_FORMBRICKS_CLOUD) + return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE; + const licenseFeatures = await getLicenseFeatures(); if (!licenseFeatures) return false; return licenseFeatures.spamProtection; diff --git a/apps/web/modules/survey/components/template-list/actions.ts b/apps/web/modules/survey/components/template-list/actions.ts index ce87843651..fcb9ebd3bf 100644 --- a/apps/web/modules/survey/components/template-list/actions.ts +++ b/apps/web/modules/survey/components/template-list/actions.ts @@ -58,7 +58,7 @@ export const createSurveyAction = authenticatedActionClient }); if (parsedInput.surveyBody.recaptcha?.enabled) { - await checkSpamProtectionPermission(); + await checkSpamProtectionPermission(organizationId); } if (parsedInput.surveyBody.followUps?.length) { diff --git a/apps/web/modules/survey/editor/actions.ts b/apps/web/modules/survey/editor/actions.ts index 46fbb1858e..9e3a3b5e08 100644 --- a/apps/web/modules/survey/editor/actions.ts +++ b/apps/web/modules/survey/editor/actions.ts @@ -63,7 +63,7 @@ export const updateSurveyAction = authenticatedActionClient }); if (parsedInput.recaptcha?.enabled) { - await checkSpamProtectionPermission(); + await checkSpamProtectionPermission(organizationId); } if (parsedInput.followUps?.length) { diff --git a/apps/web/modules/survey/editor/page.tsx b/apps/web/modules/survey/editor/page.tsx index 483170c3c8..f3731ce9d6 100644 --- a/apps/web/modules/survey/editor/page.tsx +++ b/apps/web/modules/survey/editor/page.tsx @@ -67,7 +67,7 @@ export const SurveyEditorPage = async (props) => { const isUserTargetingAllowed = await getIsContactsEnabled(); const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan); const isSurveyFollowUpsAllowed = await getSurveyFollowUpsPermission(organizationBilling.plan); - const isSpamProtectionAllowed = await getIsSpamProtectionEnabled(); + const isSpamProtectionAllowed = await getIsSpamProtectionEnabled(organizationBilling.plan); const userEmail = await getUserEmail(session.user.id); const projectLanguages = await getProjectLanguages(projectWithTeamIds.id); diff --git a/apps/web/modules/survey/lib/permission.test.ts b/apps/web/modules/survey/lib/permission.test.ts index 0edfd638cd..d10cbfe3ef 100644 --- a/apps/web/modules/survey/lib/permission.test.ts +++ b/apps/web/modules/survey/lib/permission.test.ts @@ -1,27 +1,52 @@ import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getOrganizationBilling } from "@/modules/survey/lib/survey"; +import { Organization } from "@prisma/client"; import { cleanup } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { OperationNotAllowedError } from "@formbricks/types/errors"; +import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { checkSpamProtectionPermission } from "./permission"; vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsSpamProtectionEnabled: vi.fn(), })); +vi.mock("@/modules/survey/lib/survey", () => ({ + getOrganizationBilling: vi.fn(), +})); + describe("checkSpamProtectionPermission", () => { + const mockOrganizationId = "mock-organization-id"; + const mockBillingData: Organization["billing"] = { + limits: { + monthly: { miu: 0, responses: 0 }, + projects: 3, + }, + period: "monthly", + periodStart: new Date(), + plan: "scale", + stripeCustomerId: "mock-stripe-customer-id", + }; + afterEach(() => { cleanup(); }); + test("throws ResourceNotFoundError if organization is not found", async () => { + vi.mocked(getOrganizationBilling).mockResolvedValue(null); + await expect(checkSpamProtectionPermission(mockOrganizationId)).rejects.toThrow(ResourceNotFoundError); + }); + test("resolves if spam protection is enabled", async () => { + vi.mocked(getOrganizationBilling).mockResolvedValue(mockBillingData); vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); - await expect(checkSpamProtectionPermission()).resolves.toBeUndefined(); + await expect(checkSpamProtectionPermission(mockOrganizationId)).resolves.toBeUndefined(); }); test("throws OperationNotAllowedError if spam protection is not enabled", async () => { + vi.mocked(getOrganizationBilling).mockResolvedValue(mockBillingData); vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); - await expect(checkSpamProtectionPermission()).rejects.toThrow(OperationNotAllowedError); - await expect(checkSpamProtectionPermission()).rejects.toThrow( + await expect(checkSpamProtectionPermission(mockOrganizationId)).rejects.toThrow(OperationNotAllowedError); + await expect(checkSpamProtectionPermission(mockOrganizationId)).rejects.toThrow( "Spam protection is not enabled for this organization" ); }); diff --git a/apps/web/modules/survey/lib/permission.ts b/apps/web/modules/survey/lib/permission.ts index 41f9dcbfd4..9d8db1a8be 100644 --- a/apps/web/modules/survey/lib/permission.ts +++ b/apps/web/modules/survey/lib/permission.ts @@ -1,14 +1,21 @@ import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; -import { OperationNotAllowedError } from "@formbricks/types/errors"; +import { getOrganizationBilling } from "@/modules/survey/lib/survey"; +import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; /** * Checks if the organization has spam protection enabled. - * + * @param {string} organizationId - The ID of the organization to check. * @returns {Promise} A promise that resolves if spam protection is enabled. + * @throws {ResourceNotFoundError} If the organization is not found. * @throws {OperationNotAllowedError} If spam protection is not enabled for the organization. */ -export const checkSpamProtectionPermission = async (): Promise => { - const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(); +export const checkSpamProtectionPermission = async (organizationId: string): Promise => { + const organizationBilling = await getOrganizationBilling(organizationId); + if (!organizationBilling) { + throw new ResourceNotFoundError("Organization", organizationId); + } + + const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organizationBilling.plan); if (!isSpamProtectionEnabled) { throw new OperationNotAllowedError("Spam protection is not enabled for this organization"); } diff --git a/apps/web/modules/survey/link/components/survey-renderer.tsx b/apps/web/modules/survey/link/components/survey-renderer.tsx index 19db772637..d8a517f819 100644 --- a/apps/web/modules/survey/link/components/survey-renderer.tsx +++ b/apps/web/modules/survey/link/components/survey-renderer.tsx @@ -1,13 +1,14 @@ import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, + IS_RECAPTCHA_CONFIGURED, PRIVACY_URL, RECAPTCHA_SITE_KEY, WEBAPP_URL, } from "@/lib/constants"; import { getSurveyDomain } from "@/lib/getSurveyUrl"; import { findMatchingLocale } from "@/lib/utils/locale"; -import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization"; import { getResponseCountBySurveyId } from "@/modules/survey/lib/response"; import { getOrganizationBilling } from "@/modules/survey/lib/survey"; @@ -57,8 +58,7 @@ export const renderSurvey = async ({ } const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan); - const isSpamProtectionAllowed = await getIsSpamProtectionEnabled(); - const isSpamProtectionEnabled = Boolean(isSpamProtectionAllowed && survey.recaptcha?.enabled); + const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled); if (survey.status !== "inProgress" && !isPreview) { return ( diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 7321e525fa..c6db019253 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -38,6 +38,7 @@ export default defineConfig({ "modules/ui/components/environmentId-base-layout/*.tsx", "modules/ui/components/survey/recaptcha.ts", "app/api/v2/client/**/responses/lib/recaptcha.ts", + "app/api/v2/client/**/responses/lib/organization.ts", "app/api/v2/client/**/responses/lib/utils.ts", "modules/ui/components/progress-bar/index.tsx", "app/(app)/environments/**/layout.tsx",