Compare commits

..

4 Commits

Author SHA1 Message Date
Dhruwang e319b44f0f fix: use product IDs to identify free plan subscription renewals
Metadata-based detection doesn't work because legacy free plans have
formbricks_plan set to "custom". Switch to matching against known free
plan product IDs (hobby and legacy free) instead.
2026-04-30 11:39:17 +05:30
Dhruwang d29cfc8880 fix: extend free plan renewal skip to cover legacy free and startup plans
Instead of only checking for hobby plans, skip renewals for any
subscription where all items are confirmed non-paid (not pro/scale/custom).
This covers hobby, legacy free, and legacy startup plans that all
generate no-op $0 renewal events.
2026-04-30 10:57:06 +05:30
Dhruwang 19178ca94d fix: guard against empty items array in hobby subscription renewal check
`[].every()` returns true (vacuous truth), so a subscription with no
items would be incorrectly identified as a hobby plan and skipped.
Add a length check before the every() call.
2026-04-30 10:46:42 +05:30
Dhruwang 45040c4754 fix: skip hobby subscription renewal webhooks to prevent Make credit drain
Monthly renewals of ~10k $0 hobby subscriptions trigger customer.subscription.updated
events that flood the Make.com webhook queue, consuming credits even when filtered.

Add isHobbySubscriptionRenewal() to detect pure billing-period-roll events on hobby
plans by checking that all line items are hobby and only renewal-related fields
(current_period_start/end, latest_invoice, billing_cycle_anchor) changed. These are
short-circuited before any processing or downstream webhook forwarding.

Closes ENG-698

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 17:33:17 +05:30
340 changed files with 4796 additions and 14806 deletions
+1 -11
View File
@@ -70,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments
# S3 Storage is required for the file upload in serverless environments like Vercel
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
@@ -106,13 +106,6 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
##########
# Other #
@@ -139,9 +132,6 @@ GITHUB_SECRET=
# Configure Google Login
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
# Keep this unset until that setting is active for the OAuth app.
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Configure Azure Active Directory Login
AZUREAD_CLIENT_ID=
-78
View File
@@ -1,78 +0,0 @@
name: Accessibility issue
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
type: bug
labels: ["accessibility", "bug"]
body:
- type: markdown
attributes:
value: |
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
- type: textarea
id: summary
attributes:
label: Summary
description: What part of Formbricks is affected and what's wrong?
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open a survey with multiple languages
2. Press Tab repeatedly
3. Focus never lands on the language switcher
validations:
required: true
- type: input
id: wcag
attributes:
label: Related WCAG criterion (if known)
placeholder: "e.g. 2.1.1 Keyboard"
- type: dropdown
id: severity
attributes:
label: Severity
options:
- "Critical — blocks a user from completing a core task"
- "High — significant barrier with no easy workaround"
- "Medium — barrier with a workaround"
- "Low — minor friction"
validations:
required: true
- type: input
id: at
attributes:
label: Assistive technology
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
- type: input
id: browser
attributes:
label: Browser and OS
placeholder: "e.g. Firefox 138 on Windows 11"
- type: dropdown
id: environment
attributes:
label: Your Environment
options:
- Formbricks Cloud (app.formbricks.com)
- Self-hosted Formbricks
validations:
required: true
- type: textarea
id: other
attributes:
label: Other information (screenshots, recordings, axe output)
-28
View File
@@ -155,31 +155,3 @@ jobs:
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
-30
View File
@@ -1,30 +0,0 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+1
View File
@@ -0,0 +1 @@
apps/web/.env
-48
View File
@@ -1,48 +0,0 @@
# Accessibility
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
## Standards
We aim to conform to:
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
- **Section 508** — for users in US public-sector contexts.
## Priorities
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
In both areas we focus on:
- Keyboard navigation with a clearly visible focus indicator
- Screen reader support through semantic HTML and correctly scoped ARIA
- Sufficient color and contrast
- Programmatically associated labels and announced status messages
## Supported Environments
- Latest two versions of Chrome, Firefox, Safari, and Edge
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
## Contributing
When contributing UI changes:
- Prefer semantic HTML over ARIA.
- Tab through your change end-to-end and confirm focus is visible at every stop.
- Label every control. Don't convey meaning by color alone.
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
## Reporting Accessibility Issues
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
+8 -8
View File
@@ -12,18 +12,18 @@
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-links": "10.3.6",
"@storybook/addon-onboarding": "10.3.6",
"@storybook/react-vite": "10.3.6",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/addon-links": "10.3.5",
"@storybook/addon-onboarding": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.6",
"storybook": "10.3.6",
"vite": "7.3.3"
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
}
}
@@ -2,7 +2,6 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
const projects = await getUserProjects(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -18,7 +18,6 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -243,7 +242,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -7,7 +7,6 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -52,18 +51,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -10,7 +10,6 @@ import {
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -81,19 +80,6 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", project.id, { name: project.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: project.id,
name: project.name,
},
{ organizationId, workspaceId: project.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
@@ -2,8 +2,6 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -27,14 +25,6 @@ const EnvLayout = async (props: {
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.project.id}
workspaceName={layoutData.project.name}
/>
)}
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
@@ -1,68 +1,30 @@
"use client";
import type { Session } from "next-auth";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface DeleteAccountProps {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
}
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
accountDeletionError,
isMultiOrgEnabled,
requiresPasswordConfirmation,
}: Readonly<DeleteAccountProps>) => {
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
? accountDeletionError[0]
: accountDeletionError;
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
return;
}
hasShownAccountDeletionError.current = true;
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
id: "account-deletion-sso-reauth-error",
});
}
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
globalThis.history.replaceState(null, "", url.toString());
}, [accountDeletionErrorCode, t]);
if (!session) {
return null;
}
@@ -70,7 +32,6 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -1,7 +1,14 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyUserPassword } from "@/lib/user/password";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
@@ -11,12 +18,92 @@ vi.mock("@formbricks/database", () => ({
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
@@ -1,16 +1,10 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import {
EMAIL_VERIFICATION_DISABLED,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
PASSWORD_RESET_DISABLED,
} from "@/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -21,14 +15,10 @@ import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslate();
const { environmentId } = params;
@@ -43,7 +33,6 @@ const Page = async (props: {
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
@@ -76,7 +65,7 @@ const Page = async (props: {
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing`
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -101,8 +90,6 @@ const Page = async (props: {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -5,7 +5,7 @@ import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
@@ -151,17 +151,12 @@ export const EnterpriseLicenseStatus = ({
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
<Trans
i18nKey="environments.settings.enterprise.questions_please_reach_out_to_email"
components={{
contactLink: (
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com"
/>
),
}}
/>
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</SettingsCard>
@@ -3,7 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -173,7 +173,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</p>
<Button asChild>
<Link
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
target="_blank"
rel="noopener noreferrer nofollow"
referrerPolicy="no-referrer">
@@ -1,11 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
FB_LOGO_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -85,7 +80,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
fbLogoUrl={FB_LOGO_URL}
user={user}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
{isMultiOrgEnabled && (
<SettingsCard
@@ -106,20 +106,18 @@ export const ResponseCardModal = ({
</DialogDescription>
</VisuallyHidden>
<DialogBody>
<div className="my-3">
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</div>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</DialogBody>
<DialogFooter>
<Button
@@ -2,12 +2,7 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
RESPONSES_PER_PAGE,
} from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -77,7 +72,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
}>
<SurveyAnalysisNavigation activeId="responses" />
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -147,7 +146,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
@@ -155,7 +153,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
@@ -163,7 +161,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
},
{
type: "projectTeam",
projectId,
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -180,18 +178,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
link_count: contactsResult.length,
},
{ organizationId, workspaceId: projectId }
);
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
@@ -11,7 +11,6 @@ import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
@@ -48,8 +47,7 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/6">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -81,12 +79,9 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/6">
<TableCell className="w-1/4">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6">
<IdBadge id={response.id} />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -31,7 +31,6 @@ interface SurveyAnalysisCTAProps {
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
isStorageConfigured: boolean;
enterpriseLicenseRequestFormUrl: string;
}
interface ModalState {
@@ -48,7 +47,6 @@ export const SurveyAnalysisCTA = ({
isContactsEnabled,
isFormbricksCloud,
isStorageConfigured,
enterpriseLicenseRequestFormUrl,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -113,12 +111,9 @@ export const SurveyAnalysisCTA = ({
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const singleUseLinkParams = await refreshSingleUseId();
if (singleUseLinkParams) {
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
}
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
@@ -236,7 +231,6 @@ export const SurveyAnalysisCTA = ({
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
)}
<SuccessMessage />
@@ -54,7 +54,6 @@ interface ShareSurveyModalProps {
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
enterpriseLicenseRequestFormUrl: string;
}
export const ShareSurveyModal = ({
@@ -70,7 +69,6 @@ export const ShareSurveyModal = ({
isReadOnly,
isStorageConfigured,
projectCustomScripts,
enterpriseLicenseRequestFormUrl,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -110,7 +108,6 @@ export const ShareSurveyModal = ({
segments,
isContactsEnabled,
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
},
disabled: survey.singleUse?.enabled,
},
@@ -2,7 +2,7 @@
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -41,7 +41,6 @@ export const AnonymousLinksTab = ({
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
const [customSingleUseId, setCustomSingleUseId] = useState("");
const [disableLinkModal, setDisableLinkModal] = useState<{
open: boolean;
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
pendingAction: () => Promise<void> | void;
} | null>(null);
const surveyUrlWithCustomSuid = useMemo(() => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", "CUSTOM-ID");
return url.toString();
}, [surveyUrl]);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
@@ -176,13 +181,10 @@ export const AnonymousLinksTab = ({
});
if (!!response?.data?.length) {
const singleUseLinkParams = response.data;
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", suId);
if (suToken) {
url.searchParams.set("suToken", suToken);
}
url.searchParams.set("suId", singleUseId);
return url.toString();
});
@@ -210,40 +212,6 @@ export const AnonymousLinksTab = ({
}
};
const handleCopyCustomSingleUseLink = async () => {
const trimmedCustomSingleUseId = customSingleUseId.trim();
if (!trimmedCustomSingleUseId) {
toast.error(t("environments.surveys.share.anonymous_links.custom_single_use_id_required"));
return;
}
try {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: false,
count: 1,
singleUseId: trimmedCustomSingleUseId,
});
const singleUseLinkParams = response?.data?.[0];
if (!singleUseLinkParams) {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
return;
}
const url = new URL(surveyUrl);
url.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
url.searchParams.set("suToken", singleUseLinkParams.suToken);
}
await navigator.clipboard.writeText(url.toString());
toast.success(t("common.copied_to_clipboard"));
} catch {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
}
};
return (
<>
<div className="flex h-full flex-col justify-between space-y-4">
@@ -311,19 +279,16 @@ export const AnonymousLinksTab = ({
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<Input
className="col-span-5 bg-white focus:border focus:border-slate-900"
value={customSingleUseId}
onChange={(event) => setCustomSingleUseId(event.target.value)}
placeholder={t(
"environments.surveys.share.anonymous_links.custom_single_use_id_placeholder"
)}
/>
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Button
variant="secondary"
disabled={!customSingleUseId.trim()}
onClick={handleCopyCustomSingleUseLink}
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
@@ -2,7 +2,7 @@
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { AuthenticationError } from "@formbricks/types/errors";
@@ -21,7 +21,6 @@ interface EmailTabProps {
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
const { t } = useTranslation();
const emailHtml = useMemo(() => {
@@ -32,40 +31,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const sanitizedEmailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
}, [emailHtmlPreview]);
const emailPreviewDocument = useMemo(() => {
if (!sanitizedEmailHtml) return "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<base target="_blank" />
<style>
:root {
color-scheme: only light;
supported-color-schemes: light;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color-scheme: only light;
}
</style>
</head>
<body>${sanitizedEmailHtml}</body>
</html>`;
}, [sanitizedEmailHtml]);
const tabs = [
{
id: "preview",
@@ -86,25 +51,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
getData();
}, [surveyId]);
useEffect(() => {
setPreviewFrameHeight(560);
}, [emailPreviewDocument]);
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
const { contentDocument } = event.currentTarget;
if (!contentDocument) {
return;
}
const nextHeight = Math.max(
contentDocument.body.scrollHeight,
contentDocument.documentElement.scrollHeight,
560
);
setPreviewFrameHeight(nextHeight);
};
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
@@ -127,9 +73,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
@@ -143,17 +87,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div data-testid="survey-email-preview-content">
{emailPreviewDocument ? (
<iframe
className="mt-2 w-full rounded-md border-0 bg-white"
data-testid="survey-email-preview-frame"
onLoad={handlePreviewFrameLoad}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={emailPreviewDocument}
style={{ height: `${previewFrameHeight}px` }}
title={t("environments.surveys.share.send_email.email_preview_tab")}
/>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
) : (
<LoadingSpinner />
)}
@@ -34,7 +34,6 @@ interface PersonalLinksTabProps {
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
enterpriseLicenseRequestFormUrl: string;
}
interface PersonalLinksFormData {
@@ -75,7 +74,6 @@ export const PersonalLinksTab = ({
surveyId,
isContactsEnabled,
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
}: PersonalLinksTabProps) => {
const { t } = useTranslation();
@@ -171,7 +169,7 @@ export const PersonalLinksTab = ({
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: enterpriseLicenseRequestFormUrl,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -1,59 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -1,12 +1,10 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const t = await getTranslate();
@@ -19,9 +17,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
const styling = getStyling(project, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return extractEmailBodyFragment(html.toString());
return htmlCleaned;
};
@@ -1,11 +0,0 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
@@ -1106,21 +1106,6 @@ describe("getSurveySummary", () => {
expect.objectContaining({ responseIds: expect.any(Array) })
);
});
test("does not pass responseIds for date-only filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = {
createdAt: {
min: new Date("2024-01-01T00:00:00.000Z"),
max: new Date("2024-01-31T23:59:59.999Z"),
},
};
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, {
createdAt: filterCriteria.createdAt,
});
});
});
describe("getResponsesForSummary", () => {
@@ -979,7 +979,7 @@ export const getSurveySummary = reactCache(
const elements = getElementsFromBlocks(survey.blocks);
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).some((filterKey) => filterKey !== "createdAt");
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
@@ -4,12 +4,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import {
DEFAULT_LOCALE,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
@@ -79,7 +74,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
}>
<SurveyAnalysisNavigation activeId="summary" />
@@ -42,25 +42,18 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
],
});
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const result = await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
capturePostHogEvent(
ctx.user.id,
"responses_exported",
{
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
workspace_id: projectId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
return result;
});
@@ -43,22 +43,14 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
return result;
})
@@ -1,160 +0,0 @@
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/jwt", () => ({
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const intent = {
id: "intent-id",
email: "delete-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
user: {
email: intent.email,
id: intent.userId,
},
} as any);
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
status: "success",
});
});
test("does not delete when the callback session does not match the intent user", async () => {
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
);
});
test("falls back to login when the intent return URL is not allowed", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
returnToUrl: "https://evil.example/settings/profile",
});
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
});
});
@@ -1,82 +0,0 @@
import "server-only";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
};
const getIntentToken = (intent: string | string[] | undefined) => {
if (Array.isArray(intent)) {
return intent[0];
}
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return "/auth/login";
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let redirectPath = "/auth/login";
if (!intentToken) {
return redirectPath;
}
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
redirectPath = getPostDeletionRedirectPath();
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId: session.user.id,
userType: "user",
targetId: session.user.id,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status: "success",
});
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
}
return redirectPath;
};
@@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
}
@@ -12,7 +12,6 @@ describe("captureSurveyResponsePostHogEvent", () => {
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
workspaceId: "ws-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
@@ -24,21 +23,15 @@ describe("captureSurveyResponsePostHogEvent", () => {
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
{
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
workspace_id: "ws-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
},
{ organizationId: "org-1", workspaceId: "ws-1" }
);
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
});
test("fires on every 100th response", async () => {
@@ -51,20 +44,10 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("fires on every 10th response up to 100", async () => {
test("does NOT fire for 2nd through 99th responses", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(10);
});
test("does NOT fire for non-milestone responses under 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 11, 25, 49, 51, 99]) {
for (const count of [2, 5, 10, 50, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
@@ -92,8 +75,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect.objectContaining({
is_first_response: false,
milestone: "200",
}),
{ organizationId: "org-1", workspaceId: "ws-1" }
})
);
});
});
@@ -2,7 +2,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
workspaceId: string;
surveyId: string;
surveyType: string;
environmentId: string;
@@ -11,36 +10,24 @@ interface SurveyResponsePostHogEventParams {
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
* then every 100th (200, 300, 400, ...).
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
workspaceId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
const isFirst = responseCount === 1;
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
if (responseCount !== 1 && responseCount % 100 !== 0) return;
if (!isFirst && !isEvery10thUnder100 && !isEvery100thAbove100) return;
capturePostHogEvent(
organizationId,
"survey_response_received",
{
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
workspace_id: workspaceId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
},
{ organizationId, workspaceId }
);
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
+14 -36
View File
@@ -1,6 +1,5 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import type { Agent } from "undici";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -16,8 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
@@ -99,19 +97,11 @@ export const POST = async (request: Request) => {
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
// Uses AbortSignal to actually cancel the underlying fetch when the timer fires —
// a Promise.race would only reject the wrapper while the fetch keeps the socket
// open, which then deadlocks dispatcher.close() (graceful drain waits for it).
const fetchWithTimeout = (
url: string,
options: RequestInit & { dispatcher?: Agent },
timeout: number = 5000
): Promise<Response> => {
return fetch(url, {
...options,
redirect: redirectMode,
signal: AbortSignal.timeout(timeout),
} as RequestInit & { dispatcher?: Agent });
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, { ...options, redirect: redirectMode }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
@@ -153,24 +143,14 @@ export const POST = async (request: Request) => {
);
}
return validateAndResolveWebhookUrl(webhook.url)
.then(async (address) => {
// Pin TCP connect to the validated IP. Without this, undici resolves DNS
// again at fetch time and an attacker-controlled domain can rebind to a
// private/internal IP after validation passed (TOCTOU SSRF).
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
try {
return await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
dispatcher,
});
} finally {
// destroy() — not close() — force-kills sockets and rejects any in-flight request
await dispatcher?.destroy();
}
})
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
@@ -327,11 +307,9 @@ export const POST = async (request: Request) => {
if (POSTHOG_KEY) {
const responseCount = await getResponseCountBySurveyId(surveyId);
const workspaceId = await getProjectIdFromEnvironmentId(environmentId);
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
workspaceId,
surveyId,
surveyType: survey.type,
environmentId,
@@ -1,13 +0,0 @@
import { Prisma } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
};
@@ -1,116 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
type TSingleUseResponseInput = Pick<TResponseInput, "singleUseId" | "meta">;
type TValidateSingleUseResponseInputResult = { singleUseId: string } | { response: Response } | null;
export const validateSingleUseResponseInput = (
survey: TSurvey,
environmentId: string,
responseInput: TSingleUseResponseInput
): TValidateSingleUseResponseInputResult => {
if (survey.type !== "link" || !survey.singleUse?.enabled) {
return null;
}
if (!ENCRYPTION_KEY) {
logger.error({ surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
if (!responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInput.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
const suToken = url.searchParams.get("suToken");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let canonicalSingleUseId: string | null = null;
try {
canonicalSingleUseId = validateSurveySingleUseLinkParams({
surveyId: survey.id,
suId,
suToken,
isEncrypted: survey.singleUse.isEncrypted,
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
});
} catch (error) {
logger.error({ error, surveyId: survey.id, environmentId }, "Failed to validate single-use id");
}
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
return { singleUseId: canonicalSingleUseId };
};
@@ -12,7 +12,7 @@ import {
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -87,18 +87,10 @@ export const GET = async (req: Request) => {
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
session.user.id,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
+2 -124
View File
@@ -1,15 +1,9 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest, handleErrorResponse } from "./auth";
import { authenticateRequest } from "./auth";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
@@ -177,7 +171,7 @@ describe("authenticateRequest", () => {
expect(result).toBeNull();
});
test("returns null by default when API key has no environment permissions", async () => {
test("returns null when API key has no environment permissions", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
@@ -198,120 +192,4 @@ describe("authenticateRequest", () => {
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
test("authenticates a valid API key with no environment permissions when explicitly allowed", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: "all" as const,
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
});
});
test("authenticates a read-only organization API key with no environment permissions", async () => {
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Read-only Organization API Key",
apiKeyEnvironments: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
});
});
describe("handleErrorResponse", () => {
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
const response = handleErrorResponse(new Error("NotAuthenticated"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("not_authenticated");
});
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
const response = handleErrorResponse(new Error("Unauthorized"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("unauthorized");
});
test("returns 409 conflict for UniqueConstraintError", async () => {
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
expect(response.status).toBe(409);
const body = await response.json();
expect(body.code).toBe("conflict");
expect(body.message).toBe("Action with name foo already exists");
});
test("returns 400 badRequest for DatabaseError", async () => {
const response = handleErrorResponse(new DatabaseError("db boom"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("db boom");
});
test("returns 400 badRequest for InvalidInputError", async () => {
const response = handleErrorResponse(new InvalidInputError("bad input"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
const response = handleErrorResponse(new Error("something else"));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Some error occurred");
});
});
+8 -30
View File
@@ -1,30 +1,21 @@
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
type AuthenticateApiKeyOptions = {
allowOrganizationOnlyApiKey?: boolean;
};
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
export const authenticateApiKey = async (
apiKey: string,
options: AuthenticateApiKeyOptions = {}
): Promise<TAuthenticationApiKey | null> => {
// Get API key with permissions
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return null;
if (!options.allowOrganizationOnlyApiKey && apiKeyData.apiKeyEnvironments.length === 0) {
return null;
}
// In the route handlers, we'll do more specific permission checks
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
if (environmentIds.length === 0) return null;
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
@@ -42,16 +33,6 @@ export const authenticateApiKey = async (
return authentication;
};
export const authenticateRequest = async (
request: NextRequest,
options: AuthenticateApiKeyOptions = {}
): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
return authenticateApiKey(apiKey, options);
};
export const handleErrorResponse = (error: any): Response => {
switch (error.message) {
case "NotAuthenticated":
@@ -59,9 +40,6 @@ export const handleErrorResponse = (error: any): Response => {
case "Unauthorized":
return responses.unauthorizedResponse();
default:
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
@@ -83,7 +78,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -183,17 +177,6 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
@@ -41,10 +41,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -61,12 +61,6 @@ export const POST = withV1ApiWrapper({
return {
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
};
} else if (error instanceof InvalidInputError) {
return {
response: responses.forbiddenResponse(error.message, true, {
surveyId: inputValidation.data.surveyId,
}),
};
} else {
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return {
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
select: {
id: true,
welcomeCard: true,
// name intentionally omitted — internal label not needed by the SDK
name: true,
questions: true,
blocks: true,
variables: true,
@@ -107,13 +107,13 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
segment: {
select: {
id: true,
filters: true,
include: {
surveys: {
select: {
id: true,
},
},
},
},
recontactDays: true,
@@ -147,28 +147,10 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("project", null);
}
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
const transformedSurveys = environmentData.surveys.map((survey) => {
const minimalSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
}
: null;
const { segment: _segment, ...surveyWithoutSegment } = survey;
const transformed = transformPrismaSurvey<TJsEnvironmentStateSurvey>({
...surveyWithoutSegment,
segment: null,
});
return { ...transformed, segment: minimalSegment };
});
// Transform surveys using existing utility
const transformedSurveys = environmentData.surveys.map((survey) =>
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
);
return {
environment: {
@@ -10,11 +10,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: {
@@ -27,9 +22,6 @@ vi.mock("@formbricks/database", () => ({
environment: {
update: vi.fn(),
},
project: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
@@ -171,7 +163,6 @@ describe("getEnvironmentState", () => {
// Default mocks for successful retrieval
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
});
afterEach(() => {
@@ -338,18 +329,11 @@ describe("getEnvironmentState", () => {
await getEnvironmentState(environmentId);
expect(capturePostHogEvent).toHaveBeenCalledWith(
environmentId,
"app_connected",
{
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
organization_id: "test-org-id",
workspace_id: "test-project-id",
},
{ organizationId: "test-org-id", workspaceId: "test-project-id" }
);
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
});
});
test("should not capture app_connected event when app setup already completed", async () => {
@@ -33,25 +33,11 @@ export const getEnvironmentState = async (
});
if (POSTHOG_KEY) {
const workspaceId = environment.project.id;
const project = await prisma.project.findUnique({
where: { id: workspaceId },
select: { organizationId: true },
capturePostHogEvent(environmentId, "app_connected", {
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
});
const organizationId = project?.organizationId;
capturePostHogEvent(
environmentId,
"app_connected",
{
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
organization_id: organizationId ?? "",
workspace_id: workspaceId,
},
organizationId ? { organizationId, workspaceId } : undefined
);
}
}
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -9,8 +9,6 @@ import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
vi.mock("server-only", () => ({}));
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
@@ -139,16 +137,6 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
});
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,14 +2,10 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[environmentId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
@@ -124,11 +120,7 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -2,15 +2,16 @@ import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -95,7 +96,10 @@ export const POST = withV1ApiWrapper({
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
@@ -125,24 +129,112 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.status !== "inProgress") {
return {
response: responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
}),
};
}
const singleUseValidationResult = validateSingleUseResponseInput(
survey,
environmentId,
responseInputData
);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
return { response: singleUseValidationResult.response };
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInputData.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInputData.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (survey.singleUse.isEncrypted) {
if (!ENCRYPTION_KEY) {
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
let decryptedSuId: string;
try {
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
} catch {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (decryptedSuId !== responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
} else if (responseInputData.singleUseId !== suId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
@@ -186,10 +278,6 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message, undefined, true),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
@@ -7,7 +7,7 @@ import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -95,18 +95,10 @@ export const GET = withV1ApiWrapper({
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
@@ -13,7 +13,7 @@ import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -101,18 +101,10 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "notion",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
@@ -10,7 +10,7 @@ import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL }
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -109,18 +109,10 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "slack",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -80,11 +80,6 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(actionClass),
};
} catch (error) {
if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
@@ -1,199 +0,0 @@
import { EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironment } from "@/lib/environment/service";
import { buildApiKeyMeResponse } from "./api-key-response";
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
const baseAuthentication = {
type: "apiKey" as const,
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [],
};
const environmentPermission = (
environmentId: string,
permission: "read" | "write" | "manage" = "read"
): TAuthenticationApiKey["environmentPermissions"][number] => ({
environmentId,
permission,
environmentType: EnvironmentType.development,
projectId: "project-id",
projectName: "Project Name",
});
describe("buildApiKeyMeResponse", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns auth metadata for an organization-read API key without environment permissions", async () => {
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
environmentPermissions: [],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns auth metadata with permissions for organization-read API keys with multiple environments", async () => {
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [
environmentPermission("env-1", "read"),
environmentPermission("env-2", "write"),
],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
environmentPermissions: [
{
environmentId: "env-1",
environmentType: EnvironmentType.development,
permissions: "read",
projectId: "project-id",
projectName: "Project Name",
},
{
environmentId: "env-2",
environmentType: EnvironmentType.development,
permissions: "write",
projectId: "project-id",
projectName: "Project Name",
},
],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns the legacy environment response for a single environment permission", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await buildApiKeyMeResponse({
...baseAuthentication,
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
test("returns the legacy environment response for an organization-read API key with one environment", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
test("returns null when an API key has neither organization read nor exactly one environment", async () => {
const response = await buildApiKeyMeResponse(baseAuthentication);
expect(response).toBeNull();
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns null when the single permitted environment no longer exists", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null);
const response = await buildApiKeyMeResponse({
...baseAuthentication,
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response).toBeNull();
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
});
@@ -1,49 +0,0 @@
import { OrganizationAccessType } from "@formbricks/types/api-key";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironment } from "@/lib/environment/service";
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
const buildApiKeyMetadataResponse = (authentication: TAuthenticationApiKey) => ({
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
environmentId: permission.environmentId,
environmentType: permission.environmentType,
permissions: permission.permission,
projectId: permission.projectId,
projectName: permission.projectName,
})),
organizationId: authentication.organizationId,
organizationAccess: authentication.organizationAccess,
});
export const buildApiKeyMeResponse = async (
authentication: TAuthenticationApiKey
): Promise<Response | null> => {
const environmentPermissionCount = authentication.environmentPermissions.length;
if (environmentPermissionCount !== 1) {
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
return Response.json(buildApiKeyMetadataResponse(authentication));
}
return null;
}
const permission = authentication.environmentPermissions[0];
const environment = await getEnvironment(permission.environmentId);
if (!environment) {
return null;
}
return Response.json({
id: environment.id,
type: environment.type,
createdAt: environment.createdAt,
updatedAt: environment.updatedAt,
appSetupCompleted: environment.appSetupCompleted,
project: {
id: permission.projectId,
name: permission.projectName,
},
});
};
+137 -10
View File
@@ -1,13 +1,104 @@
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { authenticateApiKey } from "@/app/api/v1/auth";
import { buildApiKeyMeResponse } from "@/app/api/v1/management/me/lib/api-key-response";
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
const apiKeySelect = {
id: true,
organizationId: true,
lastUsedAt: true,
apiKeyEnvironments: {
select: {
environment: {
select: {
id: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
appSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
},
},
hashedKey: true,
};
type ApiKeyData = {
id: string;
hashedKey: string;
organizationId: string;
lastUsedAt: Date | null;
apiKeyEnvironments: Array<{
permission: string;
environment: {
id: string;
type: string;
createdAt: Date;
updatedAt: Date;
projectId: string;
appSetupCompleted: boolean;
project: {
id: string;
name: string;
};
};
}>;
};
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const v2Parsed = parseApiKeyV2(apiKey);
if (v2Parsed) {
return validateV2ApiKey(v2Parsed);
}
return validateLegacyApiKey(apiKey);
};
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
// Step 1: Fast SHA-256 lookup by indexed lookupHash
const lookupHash = hashSha256(v2Parsed.secret);
const apiKeyData = await prisma.apiKey.findUnique({
where: { lookupHash },
select: apiKeySelect,
});
// Step 2: Security verification with bcrypt
// Always perform bcrypt verification to prevent timing attacks
// Use a control hash when API key doesn't exist to maintain constant timing
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
if (!apiKeyData || !isValid) return null;
return apiKeyData;
};
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const hashedKey = hashSha256(apiKey);
const result = await prisma.apiKey.findFirst({
where: { hashedKey },
select: apiKeySelect,
});
return result;
};
const checkRateLimit = async (userId: string) => {
try {
await applyRateLimit(rateLimitConfigs.api.v1, userId);
@@ -19,23 +110,59 @@ const checkRateLimit = async (userId: string) => {
return null;
};
const handleApiKeyAuthentication = async (apiKey: string) => {
const authentication = await authenticateApiKey(apiKey, { allowOrganizationOnlyApiKey: true });
const updateApiKeyUsage = async (apiKeyId: string) => {
await prisma.apiKey.update({
where: { id: apiKeyId },
data: { lastUsedAt: new Date() },
});
};
if (!authentication) {
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyEnvironments[0].environment;
return Response.json({
id: env.id,
type: env.type,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
project: {
id: env.projectId,
name: env.project.name,
},
});
};
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
return (
apiKeyData.apiKeyEnvironments.length === 1 &&
ALLOWED_PERMISSIONS.includes(
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
)
);
};
const handleApiKeyAuthentication = async (apiKey: string) => {
const apiKeyData = await validateApiKey(apiKey);
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
const rateLimitError = await checkRateLimit(authentication.apiKeyId);
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
// Fire-and-forget: update lastUsedAt in the background without blocking the response
updateApiKeyUsage(apiKeyData.id).catch((error) => {
console.error("Failed to update API key usage:", error);
});
}
const rateLimitError = await checkRateLimit(apiKeyData.id);
if (rateLimitError) return rateLimitError;
const apiKeyMeResponse = await buildApiKeyMeResponse(authentication);
if (!apiKeyMeResponse) {
if (!isValidApiKeyEnvironment(apiKeyData)) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return apiKeyMeResponse;
return buildEnvironmentResponse(apiKeyData);
};
const handleSessionAuthentication = async () => {
@@ -3,7 +3,7 @@ import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = withV1ApiWrapper({
@@ -56,22 +56,13 @@ export const GET = withV1ApiWrapper({
};
}
const singleUseLinkParams = generateSurveySingleUseLinkParamsList(
limit,
survey.id,
survey.singleUse.isEncrypted
);
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const surveyLink = new URL(`${publicDomain}/s/${survey.id}`);
surveyLink.searchParams.set("suId", suId);
if (suToken) {
surveyLink.searchParams.set("suToken", suToken);
}
return surveyLink.toString();
});
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return {
response: responses.successResponse(surveyLinks),
@@ -1,68 +0,0 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getEnvironmentIdsByOrganizationId } from "./environment";
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("getEnvironmentIdsByOrganizationId", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("returns environment IDs for all projects in an organization", async () => {
vi.mocked(prisma.environment.findMany).mockResolvedValue([{ id: "env-1" }, { id: "env-2" }] as any);
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
expect(result).toEqual(["env-1", "env-2"]);
expect(prisma.environment.findMany).toHaveBeenCalledWith({
where: {
project: {
organizationId: "clh6pzwx90000e9ogjr0mf7so",
},
},
select: {
id: true,
},
});
});
test("returns an empty list when the organization has no environments", async () => {
vi.mocked(prisma.environment.findMany).mockResolvedValue([]);
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
expect(result).toEqual([]);
});
test("throws DatabaseError for known Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findMany).mockRejectedValue(prismaError);
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(
DatabaseError
);
});
test("rethrows unknown errors", async () => {
const error = new Error("boom");
vi.mocked(prisma.environment.findMany).mockRejectedValue(error);
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(error);
});
});
@@ -1,34 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getEnvironmentIdsByOrganizationId = reactCache(
async (organizationId: string): Promise<string[]> => {
validateInputs([organizationId, ZId]);
try {
const environments = await prisma.environment.findMany({
where: {
project: {
organizationId,
},
},
select: {
id: true,
},
});
return environments.map((environment) => environment.id);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TOrganization } from "@formbricks/types/organizations";
import {
TSurvey,
@@ -10,8 +9,7 @@ import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { getEnvironmentIdsByOrganizationId } from "./environment";
import { checkFeaturePermissions, getReadableEnvironmentIds } from "./utils";
import { checkFeaturePermissions } from "./utils";
// Mock dependencies
vi.mock("@/app/lib/api/response", () => ({
@@ -36,10 +34,6 @@ vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
}));
vi.mock("./environment", () => ({
getEnvironmentIdsByOrganizationId: vi.fn(),
}));
const mockOrganization: TOrganization = {
id: "test-org",
name: "Test Organization",
@@ -114,109 +108,6 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
followUps: [],
};
const baseAuthentication = {
type: "apiKey" as const,
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [],
};
const environmentPermission = (
environmentId: string,
permission: "read" | "write" | "manage"
): TAuthenticationApiKey["environmentPermissions"][number] => ({
environmentId,
permission,
environmentType: "development",
projectId: `project-${environmentId}`,
projectName: `Project ${environmentId}`,
});
describe("getReadableEnvironmentIds", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("returns all organization environments when API key has organization read access", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(result).toEqual(["env-1", "env-2"]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns an empty list when an organization-read API key belongs to an organization without environments", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(result).toEqual([]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns all organization environments when API key has organization write access", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1"]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: false,
write: true,
},
},
});
expect(result).toEqual(["env-1"]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns de-duplicated environment permissions that allow GET without organization access", async () => {
const result = await getReadableEnvironmentIds({
...baseAuthentication,
environmentPermissions: [
environmentPermission("env-1", "read"),
environmentPermission("env-2", "write"),
environmentPermission("env-3", "manage"),
environmentPermission("env-1", "read"),
],
});
expect(result).toEqual(["env-1", "env-2", "env-3"]);
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
});
test("returns null when the API key has no readable access", async () => {
const result = await getReadableEnvironmentIds(baseAuthentication);
expect(result).toBeNull();
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
});
});
describe("checkFeaturePermissions", () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
@@ -1,32 +1,10 @@
import { OrganizationAccessType } from "@formbricks/types/api-key";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasOrganizationAccess, hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { getEnvironmentIdsByOrganizationId } from "./environment";
export const getReadableEnvironmentIds = async (
authentication: TAuthenticationApiKey
): Promise<string[] | null> => {
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
return getEnvironmentIdsByOrganizationId(authentication.organizationId);
}
const environmentIds = authentication.environmentPermissions
.filter((permission) =>
hasPermission(authentication.environmentPermissions, permission.environmentId, "GET")
)
.map((permission) => permission.environmentId);
const readableEnvironmentIds = Array.from(new Set(environmentIds));
return readableEnvironmentIds.length > 0 ? readableEnvironmentIds : null;
};
export const checkFeaturePermissions = async (
surveyData: TSurveyCreateInputWithEnvironmentId,
@@ -1,10 +1,7 @@
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import {
checkFeaturePermissions,
getReadableEnvironmentIds,
} from "@/app/api/v1/management/surveys/lib/utils";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -20,7 +17,6 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
allowOrganizationOnlyApiKey: true,
handler: async ({ req, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
@@ -31,10 +27,9 @@ export const GET = withV1ApiWrapper({
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = await getReadableEnvironmentIds(authentication);
if (!environmentIds) {
return { response: responses.unauthorizedResponse() };
}
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
@@ -21,7 +21,6 @@ vi.mock("react", async () => {
});
const contactId = "test-contact-id";
const environmentId = "test-env-id";
describe("doesContactExist", () => {
afterEach(() => {
@@ -36,23 +35,23 @@ describe("doesContactExist", () => {
environmentId: "test-env",
});
const result = await doesContactExist(contactId, environmentId);
const result = await doesContactExist(contactId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId, environmentId },
where: { id: contactId },
select: { id: true },
});
});
test("should return false if contact does not exist in the environment", async () => {
test("should return false if contact does not exist", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId, environmentId);
const result = await doesContactExist(contactId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId, environmentId },
where: { id: contactId },
select: { id: true },
});
});
@@ -1,11 +1,10 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const doesContactExist = reactCache(async (id: string, environmentId: string): Promise<boolean> => {
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
environmentId,
},
select: {
id: true,
@@ -1,12 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
@@ -71,7 +66,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -87,7 +81,7 @@ describe("createDisplay", () => {
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -114,14 +108,14 @@ describe("createDisplay", () => {
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
});
test("should create a display without contact if contact does not exist in the environment", async () => {
test("should create a display even if contact does not exist", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -148,24 +142,13 @@ describe("createDisplay", () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, environmentId },
});
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
const { contactId, surveyId, environmentId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId, environmentId) : false;
const contactExists = contactId ? await doesContactExist(contactId) : false;
const survey = await prisma.survey.findUnique({
where: {
@@ -26,10 +26,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromEnvironmentId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,9 @@
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { applyClientApiRateLimit } from "@/app/lib/api/client-rate-limit";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -52,11 +51,6 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const rateLimitResponse = await applyClientApiRateLimit({ request, environmentId: params.environmentId });
if (rateLimitResponse) {
return rateLimitResponse;
}
const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
if ("response" in validatedInput) {
@@ -85,12 +79,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
}
if (error instanceof InvalidInputError) {
return responses.forbiddenResponse(error.message, true, {
surveyId: displayInputData.surveyId,
});
}
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
@@ -0,0 +1,135 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getEnvironmentState: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[environmentId]/environment/lib/environmentState", () => ({
getEnvironmentState: mocks.getEnvironmentState,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getEnvironmentState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("./route");
const response = await GET(request, {
params: Promise.resolve({
environmentId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -13,7 +13,6 @@ vi.mock("@formbricks/database", () => ({
}));
const contactId = "test-contact-id";
const environmentId = "test-env-id";
const mockContact = {
id: contactId,
attributes: [
@@ -33,10 +32,10 @@ describe("getContact", () => {
mockContact as unknown as Awaited<ReturnType<typeof prisma.contact.findUnique>>
);
const result = await getContact(contactId, environmentId);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId, environmentId },
where: { id: contactId },
select: {
id: true,
attributes: {
@@ -56,10 +55,10 @@ describe("getContact", () => {
test("should return null when contact is not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(contactId, environmentId);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId, environmentId },
where: { id: contactId },
select: {
id: true,
attributes: {
@@ -2,16 +2,9 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
type TContactAttributeResult = {
attributeKey: {
key: string;
};
value: string;
};
export const getContact = reactCache(async (contactId: string, environmentId: string) => {
export const getContact = reactCache(async (contactId: string) => {
const contact = await prisma.contact.findUnique({
where: { id: contactId, environmentId },
where: { id: contactId },
select: {
id: true,
attributes: {
@@ -27,13 +20,10 @@ export const getContact = reactCache(async (contactId: string, environmentId: st
return null;
}
const contactAttributes = contact.attributes.reduce(
(acc: TContactAttributes, attr: TContactAttributeResult) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{}
);
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {});
return {
id: contact.id,
@@ -236,51 +236,6 @@ describe("createResponse V2", () => {
const result = await createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient);
expect(result.tags).toEqual([mockTag]);
});
test("should create response with contact when contact belongs to the environment", async () => {
const responseInputWithContact = {
...mockResponseInput,
contactId,
};
const result = await createResponse(
responseInputWithContact,
mockTx as unknown as Prisma.TransactionClient
);
expect(getContact).toHaveBeenCalledWith(contactId, environmentId);
expect(mockTx.response.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
contact: { connect: { id: contactId } },
contactAttributes: mockContact.attributes,
}),
})
);
expect(result.contact).toEqual({
id: contactId,
userId,
});
});
test("should create response without contact when contact is not found in the environment", async () => {
vi.mocked(getContact).mockResolvedValue(null);
const responseInputWithContact = {
...mockResponseInput,
contactId,
};
const result = await createResponse(
responseInputWithContact,
mockTx as unknown as Prisma.TransactionClient
);
const createArgs = mockTx.response.create.mock.calls[0][0];
expect(getContact).toHaveBeenCalledWith(contactId, environmentId);
expect(createArgs.data).not.toHaveProperty("contact");
expect(createArgs.data).not.toHaveProperty("contactAttributes");
expect(result.contact).toBeNull();
});
});
describe("createResponseWithQuotaEvaluation V2", () => {
@@ -6,10 +6,6 @@ import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@fo
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[environmentId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -21,7 +17,7 @@ import { getContact } from "./contact";
export const createResponseWithQuotaEvaluation = async (
responseInput: TResponseInputV2
): Promise<TResponseWithQuotaFull> => {
const txResponse = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await createResponse(responseInput, tx);
const quotaResult = await evaluateResponseQuotas({
@@ -105,7 +101,7 @@ export const createResponse = async (
}
if (contactId) {
contact = await getContact(contactId, environmentId);
contact = await getContact(contactId);
}
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
@@ -132,9 +128,12 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
@@ -9,7 +9,6 @@ import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { generateSurveySingleUseSignature } from "@/lib/utils/single-use-surveys";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
vi.mock("@/lib/i18n/utils", () => ({
@@ -26,7 +25,6 @@ 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 })),
forbiddenResponse: vi.fn((message) => new Response(message, { status: 403 })),
},
}));
@@ -54,11 +52,6 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/constants", () => ({
ENCRYPTION_KEY: "test-key",
}));
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "test-key",
},
}));
const mockSurvey: TSurvey = {
id: "survey-1",
@@ -97,7 +90,6 @@ const mockSurvey: TSurvey = {
showLanguageSwitch: false,
blocks: [],
isCaptureIpEnabled: false,
isAutoProgressingEnabled: false,
metadata: {},
slug: null,
};
@@ -119,7 +111,6 @@ const mockBillingData: TOrganizationBilling = {
usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id",
};
const validSingleUseId = "cm8f4x9mm0001gx9h5b7d7h3q";
describe("checkSurveyValidity", () => {
beforeEach(() => {
@@ -139,19 +130,6 @@ describe("checkSurveyValidity", () => {
);
});
test.each(["draft", "paused", "completed"] as const)(
"should return forbiddenResponse when survey status is %s",
async (status) => {
const survey = { ...mockSurvey, status } as TSurvey;
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith("Survey is not accepting submissions", true, {
surveyId: mockSurvey.id,
});
}
);
test("should return null if recaptcha is not enabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 } };
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
@@ -244,14 +222,10 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
@@ -263,14 +237,10 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if meta.url is invalid", async () => {
@@ -284,8 +254,7 @@ describe("checkSurveyValidity", () => {
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid URL in response metadata",
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" }),
true
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
);
});
@@ -299,20 +268,16 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
@@ -321,20 +286,15 @@ describe("checkSurveyValidity", () => {
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
expect(resultEncryptedMismatch?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const suToken = generateSurveySingleUseSignature(survey.id, "su-2");
const url = `https://example.com/?suId=su-2&suToken=${suToken}`;
const url = "https://example.com/?suId=su-2";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
@@ -342,17 +302,13 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if not encrypted and suToken is missing", async () => {
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "env-1", {
@@ -360,39 +316,16 @@ describe("checkSurveyValidity", () => {
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return null if singleUse is enabled, not encrypted, and signed suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const suToken = generateSurveySingleUseSignature(survey.id, "su-1");
const url = `https://example.com/?suId=su-1&suToken=${suToken}`;
const responseInput = {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
};
const result = await checkSurveyValidity(survey, "env-1", responseInput);
expect(result).toBeNull();
expect(responseInput.singleUseId).toBe("su-1");
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: validSingleUseId,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
@@ -1,10 +1,11 @@
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
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";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -19,18 +20,53 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
}
if (survey.status !== "inProgress") {
return responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
});
}
const singleUseValidationResult = validateSingleUseResponseInput(survey, environmentId, responseInput);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
return singleUseValidationResult.response;
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (!responseInput.meta?.url) {
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
});
}
let url;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return responses.badRequestResponse("Invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
});
}
const suId = url.searchParams.get("suId");
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (survey.singleUse.isEncrypted) {
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
responseInput.singleUseId = singleUseValidationResult.singleUseId;
}
if (survey.recaptcha?.enabled) {
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});
@@ -4,7 +4,6 @@ import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/erro
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { applyClientApiRateLimit } from "@/app/lib/api/client-rate-limit";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -36,7 +35,10 @@ type TValidatedResponseInputResult =
| { response: Response };
const getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
@@ -201,11 +203,6 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const rateLimitResponse = await applyClientApiRateLimit({ request, environmentId: params.environmentId });
if (rateLimitResponse) {
return rateLimitResponse;
}
const validatedInput = await parseAndValidateResponseInput(request, params.environmentId);
if ("response" in validatedInput) {
@@ -1,107 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TooManyRequestsError } from "@formbricks/types/errors";
const mocks = vi.hoisted(() => ({
applyClientRateLimit: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyClientRateLimit: mocks.applyClientRateLimit,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "ck12345678901234567890123";
describe("client-rate-limit", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyClientRateLimit.mockResolvedValue({ allowed: true });
});
test("applies the client rate limit for a valid environment ID", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`);
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId });
expect(response).toBeNull();
expect(mocks.applyClientRateLimit).toHaveBeenCalledWith(environmentId, undefined);
});
test("passes custom configs to the client rate limit helper", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/storage`);
const customRateLimitConfig = {
interval: 60,
allowedPerInterval: 5,
namespace: "storage:upload",
};
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({
request,
environmentId,
customRateLimitConfig,
});
expect(response).toBeNull();
expect(mocks.applyClientRateLimit).toHaveBeenCalledWith(environmentId, customRateLimitConfig);
});
test("rejects invalid environment IDs before touching Redis", async () => {
const request = new Request("https://api.test/api/v2/client/not-a-cuid/displays");
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId: "not-a-cuid" });
expect(response?.status).toBe(400);
expect(await response?.json()).toEqual({
code: "bad_request",
message: "Invalid environment ID format",
details: {},
});
expect(mocks.applyClientRateLimit).not.toHaveBeenCalled();
});
test("returns 429 for TooManyRequestsError without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`);
mocks.applyClientRateLimit.mockRejectedValue(
new TooManyRequestsError("Maximum number of requests reached. Please try again later.")
);
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId });
expect(response?.status).toBe(429);
expect(await response?.json()).toEqual({
code: "too_many_requests",
message: "Maximum number of requests reached. Please try again later.",
details: {},
});
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("returns a generic 500 and reports unexpected rate limit failures", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`);
const underlyingError = new Error("Failed to hash IP");
mocks.applyClientRateLimit.mockRejectedValue(underlyingError);
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId });
expect(response?.status).toBe(500);
expect(await response?.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
});
-70
View File
@@ -1,70 +0,0 @@
import { ZEnvironmentId } from "@formbricks/types/environment";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { responses } from "@/app/lib/api/response";
import { applyClientRateLimit } from "@/modules/core/rate-limit/helpers";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
const rateLimitMessage = "Maximum number of requests reached. Please try again later.";
const unexpectedErrorMessage = "Something went wrong. Please try again.";
export const validateClientEnvironmentId = (environmentId: string): string | null => {
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
return environmentIdValidation.success ? environmentIdValidation.data : null;
};
export const getInvalidClientEnvironmentIdResponse = (cors = true): Response => {
return responses.badRequestResponse("Invalid environment ID format", undefined, cors);
};
export const isTooManyRequestsError = (error: unknown): boolean => {
return (
error instanceof TooManyRequestsError || (error instanceof Error && error.name === "TooManyRequestsError")
);
};
export const getRateLimitErrorResponse = ({
request,
error,
cors = true,
}: {
request: Request;
error: unknown;
cors?: boolean;
}): Response => {
if (isTooManyRequestsError(error)) {
return responses.tooManyRequestsResponse(rateLimitMessage, cors);
}
const response = responses.internalServerErrorResponse(unexpectedErrorMessage, cors);
reportApiError({
request,
status: response.status,
error,
});
return response;
};
export const applyClientApiRateLimit = async ({
request,
environmentId,
customRateLimitConfig,
cors = true,
}: {
request: Request;
environmentId: string;
customRateLimitConfig?: TRateLimitConfig;
cors?: boolean;
}): Promise<Response | null> => {
const validEnvironmentId = validateClientEnvironmentId(environmentId);
if (!validEnvironmentId) {
return getInvalidClientEnvironmentIdResponse(cors);
}
try {
await applyClientRateLimit(validEnvironmentId, customRateLimitConfig);
return null;
} catch (error) {
return getRateLimitErrorResponse({ request, error, cors });
}
};
+1 -1
View File
@@ -152,7 +152,7 @@ describe("API Response Utilities", () => {
test("should use custom cache control header when provided", () => {
const message = "Something went wrong";
const customCache = "no-cache";
const response = responses.internalServerErrorResponse(message, false, {}, customCache);
const response = responses.internalServerErrorResponse(message, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
+1 -2
View File
@@ -217,7 +217,6 @@ const successResponse = (data: Object, cors: boolean = false, cache: string = "p
const internalServerErrorResponse = (
message: string,
cors: boolean = false,
details: ApiErrorResponse["details"] = {},
cache: string = "private, no-store"
) => {
const headers = {
@@ -229,7 +228,7 @@ const internalServerErrorResponse = (
{
code: "internal_server_error",
message,
details,
details: {},
} as ApiErrorResponse,
{
status: 500,
+7 -184
View File
@@ -3,7 +3,6 @@ import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
@@ -61,7 +60,7 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyClientRateLimit: vi.fn(),
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
}));
@@ -69,7 +68,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
clientEnvironment: { windowMs: 60000, max: 1000 },
v1: { windowMs: 60000, max: 1000 },
},
},
@@ -446,7 +444,7 @@ describe("withV1ApiWrapper", () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
@@ -455,19 +453,18 @@ describe("withV1ApiWrapper", () => {
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyClientRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/ck12345678901234567890123/displays" });
const req = createMockRequest({ url: "/api/v1/client/displays" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyClientRateLimit).toHaveBeenCalledWith("ck12345678901234567890123", undefined);
expect(handler).toHaveBeenCalledWith({
req,
props: undefined,
@@ -476,81 +473,6 @@ describe("withV1ApiWrapper", () => {
});
});
test("passes custom client rate limit config with the environment ID", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyClientRateLimit).mockResolvedValue({ allowed: true });
const customRateLimitConfig = {
interval: 60,
allowedPerInterval: 5,
namespace: "storage:upload",
};
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/ck12345678901234567890123/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyClientRateLimit).toHaveBeenCalledWith("ck12345678901234567890123", customRateLimitConfig);
expect(handler).toHaveBeenCalled();
});
test("rejects invalid client environment IDs before rate limiting", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyClientRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/not-a-cuid/displays" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
code: "bad_request",
message: "Invalid environment ID format",
details: {},
});
expect(applyClientRateLimit).not.toHaveBeenCalled();
expect(handler).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
{
pathname: "/api/v1/client/not-a-cuid/displays",
environmentId: "not-a-cuid",
},
"Invalid client API environment ID for rate limiting"
);
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -619,9 +541,9 @@ describe("withV1ApiWrapper", () => {
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(applyRateLimit).mockRejectedValue(
new TooManyRequestsError("Maximum number of requests reached. Please try again later.")
);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
@@ -633,55 +555,6 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("returns a generic error for unexpected client rate limit failures", async () => {
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const underlyingError = new Error("Failed to hash IP");
vi.mocked(applyClientRateLimit).mockRejectedValue(underlyingError);
const handler = vi.fn();
const req = createMockRequest({
url: "/api/v1/client/ck12345678901234567890123/displays",
headers: new Map([["x-request-id", "rate-limit-failure"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(handler).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
correlationId: "rate-limit-failure",
path: "/api/v1/client/ck12345678901234567890123/displays",
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
status: 500,
}),
}),
})
);
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
@@ -714,56 +587,6 @@ describe("withV1ApiWrapper", () => {
});
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
test("does not allow organization-only API keys by default", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v1/management/action-classes" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const response = await wrapped(req, undefined);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: false });
});
test("allows organization-only API keys when the route opts in", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, allowOrganizationOnlyApiKey: true });
const response = await wrapped(req, undefined);
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: true });
});
});
describe("buildAuditLogBaseObject", () => {
+14 -64
View File
@@ -4,12 +4,6 @@ import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import {
applyClientApiRateLimit,
getInvalidClientEnvironmentIdResponse,
getRateLimitErrorResponse,
validateClientEnvironmentId,
} from "@/app/lib/api/client-rate-limit";
import { responses } from "@/app/lib/api/response";
import {
AuthenticationMethod,
@@ -19,7 +13,7 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
@@ -52,11 +46,6 @@ export interface TWithV1ApiWrapperParams<
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
/**
* Most v1 management routes are environment-scoped. Enable this only for routes that explicitly
* support organization-only API keys.
*/
allowOrganizationOnlyApiKey?: boolean;
}
enum ApiV1RouteTypeEnum {
@@ -65,11 +54,11 @@ enum ApiV1RouteTypeEnum {
Integration = "integration",
}
const clientEnvironmentPathRegex = /^\/api\/v\d+\/client\/([^/]+)/;
const getClientEnvironmentIdFromPathname = (pathname: string): string | null => {
const match = clientEnvironmentPathRegex.exec(pathname);
return match?.[1] ?? null;
/**
* Apply client-side API rate limiting (IP-based)
*/
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
};
/**
@@ -78,11 +67,8 @@ const getClientEnvironmentIdFromPathname = (pathname: string): string | null =>
const handleRateLimiting = async (
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
req: NextRequest,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => {
const pathname = req.nextUrl.pathname;
try {
if (authentication) {
if ("user" in authentication) {
@@ -98,33 +84,10 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
const environmentIdFromPath = getClientEnvironmentIdFromPathname(pathname);
if (!environmentIdFromPath) {
logger.error({ pathname }, "Unable to determine client API environment ID for rate limiting");
return responses.badRequestResponse("Environment ID is required", undefined, true);
}
const validEnvironmentId = validateClientEnvironmentId(environmentIdFromPath);
if (!validEnvironmentId) {
logger.warn(
{ pathname, environmentId: environmentIdFromPath },
"Invalid client API environment ID for rate limiting"
);
return getInvalidClientEnvironmentIdResponse();
}
return await applyClientApiRateLimit({
request: req,
environmentId: validEnvironmentId,
customRateLimitConfig,
});
await applyClientRateLimit(customRateLimitConfig);
}
} catch (error) {
return getRateLimitErrorResponse({
request: req,
error,
cors: routeType === ApiV1RouteTypeEnum.Client,
});
return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded");
}
return null;
@@ -179,17 +142,16 @@ const setupAuditLog = (
*/
const handleAuthentication = async (
authenticationMethod: AuthenticationMethod,
req: NextRequest,
allowOrganizationOnlyApiKey = false
req: NextRequest
): Promise<TApiV1Authentication> => {
switch (authenticationMethod) {
case AuthenticationMethod.ApiKey:
return await authenticateRequest(req, { allowOrganizationOnlyApiKey });
return await authenticateRequest(req);
case AuthenticationMethod.Session:
return await getServerSession(authOptions);
case AuthenticationMethod.Both: {
const session = await getServerSession(authOptions);
return session ?? (await authenticateRequest(req, { allowOrganizationOnlyApiKey }));
return session ?? (await authenticateRequest(req));
}
case AuthenticationMethod.None:
return null;
@@ -289,14 +251,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const {
handler,
action,
targetType,
customRateLimitConfig,
unauthenticatedResponse,
allowOrganizationOnlyApiKey,
} = params;
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -315,7 +270,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
}
// === Authentication ===
const authentication = await handleAuthentication(authenticationMethod, req, allowOrganizationOnlyApiKey);
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
@@ -331,12 +286,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(
authentication,
routeType,
req,
customRateLimitConfig
);
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
if (rateLimitResponse) return rateLimitResponse;
}
@@ -1,57 +0,0 @@
"use client";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
interface PostHogGroupIdentifyProps {
organizationId: string;
organizationName: string;
workspaceId: string;
workspaceName: string;
}
export const PostHogGroupIdentify = ({
organizationId,
organizationName,
workspaceId,
workspaceName,
}: PostHogGroupIdentifyProps) => {
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
const applyGroups = () => {
posthog.group("organization", organizationId, { name: organizationName });
posthog.group("workspace", workspaceId, { name: workspaceName });
};
if (posthog.__loaded) {
applyGroups();
return;
}
// PostHogIdentify (in app layout) initialises posthog from a sibling
// useEffect; effect order isn't guaranteed, so poll briefly until loaded.
const intervalId = setInterval(() => {
if (cancelledRef.current) return;
if (posthog.__loaded) {
applyGroups();
clearInterval(intervalId);
}
}, 50);
const timeoutId = setTimeout(() => {
cancelledRef.current = true;
clearInterval(intervalId);
}, 5000);
return () => {
cancelledRef.current = true;
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [organizationId, organizationName, workspaceId, workspaceName]);
return null;
};
@@ -4,10 +4,10 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { gethasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
@@ -21,7 +21,7 @@ export const createOrganizationAction = authenticatedActionClient
.inputSchema(ZCreateOrganizationAction)
.action(
withAuditLogging("created", "organization", async ({ ctx, parsedInput }) => {
const hasNoOrganizations = await getHasNoOrganizations();
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
@@ -50,17 +50,10 @@ export const createOrganizationAction = authenticatedActionClient
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
capturePostHogEvent(
ctx.user.id,
"organization_created",
{
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
},
{ organizationId: newOrganization.id }
);
capturePostHogEvent(ctx.user.id, "organization_created", {
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
});
return newOrganization;
})
-28
View File
@@ -1,28 +0,0 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Page = async () => {
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
getServerSession(authOptions),
getIsFreshInstance(),
getHasNoOrganizations(),
]);
if (isFreshInstance) {
return redirect("/setup/intro");
}
if (hasNoOrganizations) {
if (session) {
return redirect("/setup/organization/create");
}
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
}
return redirect("/");
};
export default Page;
+35 -41
View File
@@ -63,8 +63,8 @@ checksums:
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
auth/signup/product_updates_description: 64c458f1da8d0a1ab921070e2b4867bd
auth/signup/product_updates_title: e59c8ec06ec05b253f766a73653fdc98
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
@@ -87,7 +87,6 @@ checksums:
billing_confirmation/upgrade_successful: 57281b5f15480a026a2f67b195d69247
c/link_expired: edccebc7cf11b1c6a54e37aedc1f1644
c/link_expired_description: dfbafe1d9932fdb2d151e136dd71a38d
c/link_expired_heading: edccebc7cf11b1c6a54e37aedc1f1644
common/accepted: ea2ed23f35f8b090b5a994ac64ec588a
common/account: 01215c12fb1cdb93bd0c84c1382bef56
common/account_settings: df8e9882a1f5c75951f3a05ddfed72ba
@@ -214,12 +213,10 @@ checksums:
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/field_placeholder: ec26d96643d86da164162204ec6c650f
common/file_size_must_be_less_than_5_mb: a7d8ef9f888bfb3de2b589947fa2a63f
common/file_storage_not_set_up: ed82fc9c54da2f16245eaea9f8aa08f3
common/file_upload_service_unavailable: 93a6a904cef89cc18d2c4a65e2d581cc
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
@@ -333,6 +330,7 @@ checksums:
common/placeholder: 88c2c168aff12ca70148fcb5f6b4c7b1
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -435,6 +433,7 @@ checksums:
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
@@ -472,7 +471,7 @@ checksums:
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
common/you_have_reached_your_monthly_response_limit_of_count: b68287d2a673228e6ee403f48aa308ca
common/you_have_reached_your_monthly_response_limit_of: 3824db23ecc3dcd2b1787b98ccfdd5f9
common/you_will_be_downgraded_to_the_community_edition_on_date: bff35b54c13e2c205dc4c19056261cc0
common/your_license_has_expired_please_renew: 3f21ae4a7deab351b143b407ece58254
emails/accept: f8cc1de4f5e3c850cfdbbc0ec831ade7
@@ -679,7 +678,6 @@ checksums:
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
environments/contacts/survey_response_created: e4a6eaac2acd8defca3ff57eae045ba6
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
@@ -735,7 +733,6 @@ checksums:
environments/integrations/create_survey_warning: e35a3f72c3c31a27650fd450b7c73c11
environments/integrations/delete_integration: ccc879ccfcf7f85bcfe09f2bc3fa0dd3
environments/integrations/delete_integration_confirmation: be7400c43d808b6f56d98b0f435dff3c
environments/integrations/follow_these_docs_to_configure_it: 44a7f05d88479b9e54e200c277cceb3d
environments/integrations/google_sheet_integration_description: 346102145ef0c7b20c64b44fd4592005
environments/integrations/google_sheets/connect_with_google_sheets: ec82132ca0a58920d9f521a0a9845dd1
environments/integrations/google_sheets/enter_a_valid_spreadsheet_url_error: 8c9fa8bd3d7872de17c5e69c8b8333e0
@@ -814,6 +811,7 @@ checksums:
environments/integrations/slack/slack_reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/slack/slack_reconnect_button_description: e5a4e7f868f76b8892998025f9dd032a
environments/integrations/slack_integration_description: 72b33dab89e4bb2cdb72082973e0a11a
environments/integrations/to_configure_it: 157de34c0ebe43b498f670e2a7ac470a
environments/integrations/webhook_integration_description: 9d02de29b3f2c836e14510f961cbb9cc
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
@@ -868,7 +866,9 @@ checksums:
environments/segments/ex_fully_activated_recurring_users: d242c30b7ad8191c19378f597e99e684
environments/segments/ex_power_users: 1923f36e6a1f034a591640299f0b5b75
environments/segments/filters_reset_successfully: c3893460d8c2970e43d906c272c517e8
environments/segments/here: 462e596d55b481e23f29b5ced669624a
environments/segments/hide_filters: 6371822c921a146ca7860a2795c94fcd
environments/segments/identifying_users: b5f7a6c79971ada956b1c45ef8d86044
environments/segments/invalid_segment: f03933d5c91a87f0a4e9e978b2cd27d1
environments/segments/invalid_segment_filters: b7b4d979e52d315faecf56c3db8e66cd
environments/segments/load_segment: 56e0e2c2a1c3f6b2035376a2240ff453
@@ -923,20 +923,21 @@ checksums:
environments/segments/segment_id: 875b44f7289197810baf2e1c03de5c77
environments/segments/segment_saved_successfully: ce116c8dc576cac49753082eb2ecb413
environments/segments/segment_updated_successfully: 2eca3ff5c645bdccd16c306d663114a1
environments/segments/segment_used_in_other_surveys_make_changes_here: 64a5cb0e02c4868af68db022717a4f95
environments/segments/segments_help_you_target_users_with_same_characteristics_easily: b18b75bda13fe408a467dd625611ea06
environments/segments/target_audience: ca47151c4f0ddb348e52ec43ce15eb03
environments/segments/this_action_resets_all_filters_in_this_survey: a7b342c25f0d3d6060bfdff38ade0682
environments/segments/this_segment_is_used_in_other_surveys: e7976706ae6355fb84bdad64f2033a70
environments/segments/title_is_required: e9b1d9309edf85645ab15cc90ae0a70f
environments/segments/unknown_filter_type: 67d05a804c1ad54434c0a90f47bb0304
environments/segments/unlock_segments_description: 5cb5aff50997e6265807a15ed1456be1
environments/segments/unlock_segments_title: 810b329adfd0967070a20f21953d37c0
environments/segments/user_targeting_only_available_when_identifying_users: ed7301d94ba54c003a39a57af736e458
environments/segments/user_targeting_is_currently_only_available_when: 9785f159fb045607b62461f38e8d3aee
environments/segments/value_cannot_be_empty: 99efd449ec19f1ecc5cf0b6807d4f315
environments/segments/value_must_be_a_number: 87516b5c69e08741fa8a6ddf64d60deb
environments/segments/value_must_be_positive: d17ad009f7845a6fbeddeb2aef532e10
environments/segments/view_filters: 791cd4bacb11e3eb0ffccee131270561
environments/segments/where: 23aecda7d27f26121b057ec7f7327069
environments/segments/with_the_formbricks_sdk: 2b185e6242edb69e1bc6e64e10dfc02a
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
@@ -1010,8 +1011,8 @@ checksums:
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
environments/settings/billing/usage_count_of_limit_used: 0bcf3f084731f9fc7cd0b0777e720567
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
@@ -1068,7 +1069,7 @@ checksums:
environments/settings/enterprise/no_credit_card_no_sales_call_just_test_it: 18f9859cdf12537b7019ecdb0a0a2b53
environments/settings/enterprise/on_request: cf9949748c15313a8fd57bf965bec16b
environments/settings/enterprise/organization_roles: 731d5028521c2a3a7bdbd7ed215dd861
environments/settings/enterprise/questions_please_reach_out_to_email: 62d2e5f69a9bad47c0b975c702603caa
environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7
environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa
environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c
environments/settings/enterprise/recheck_license_instance_mismatch: 655cd1cce2f25b100439d8725c1e72f2
@@ -1167,15 +1168,12 @@ checksums:
environments/settings/profile/confirm_delete_my_account: b8715ff52c7109ed6b6c31a00e44013d
environments/settings/profile/confirm_your_current_password_to_get_started: 7815146bd37e6648a6a6637bf9e0095f
environments/settings/profile/delete_account: f5f7b88ff3122fb0d3a330e2b99d7e3d
environments/settings/profile/delete_account_confirmation_required: 92d7d248ffde0223744364d4cc4a6e07
environments/settings/profile/disable_two_factor_authentication: 2f5396bad4f84ad7160421021042ee12
environments/settings/profile/disable_two_factor_authentication_description: f81ac20c3864f1a252a4813b5f9b719a
environments/settings/profile/each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator: ecf77d8d17ccf64e1effc6ab131fea56
environments/settings/profile/email_change_initiated: b9a69748076f2860d9124f745b75d392
environments/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
environments/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
environments/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
environments/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
environments/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
environments/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
environments/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
@@ -1186,8 +1184,6 @@ checksums:
environments/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
environments/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
environments/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
environments/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
environments/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
environments/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
environments/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
environments/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -1196,7 +1192,6 @@ checksums:
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
@@ -1282,12 +1277,13 @@ checksums:
environments/surveys/edit/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
environments/surveys/edit/adjust_theme_in_look_and_feel_settings: 51372cb5ee8d3d42389bc95468866ad1
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
environments/surveys/edit/all_other_answers_will_continue_to_fallback: 81841a9911236672ed262e520c24e821
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
environments/surveys/edit/any_is_true: 32c9f3998984fd32a2b5bc53f2d97429
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
@@ -1299,10 +1295,10 @@ checksums:
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
environments/surveys/edit/automatically_close_survey_after_n_seconds_if_no_response: 7f8ea038a731a792f744d79fabba4aa9
environments/surveys/edit/automatically_close_survey_after: 3e1c400a4b226c875dc8337e3b204d85
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
environments/surveys/edit/automatically_mark_complete_after_n_responses: b9145087d7a01b261dc03204347f118c
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
environments/surveys/edit/back_button_label: 504551d78645d968fcee95e3dfa5586f
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
@@ -1360,11 +1356,11 @@ checksums:
environments/surveys/edit/columns: 14896556dc1535d70198854757f704ec
environments/surveys/edit/company: 9e36d11fa82d1bd4563eef2abc4fcfba
environments/surveys/edit/company_logo: aed15a185c680fb2159a75b57e24e885
environments/surveys/edit/completed_responses: 2fc333d023c8f9d74a58b6bd61ada411
environments/surveys/edit/concat: 9f6420a83aa45c80f53770f18d11505b
environments/surveys/edit/conditional_logic: 8da827c6111ff1638af11d37f5a72bf7
environments/surveys/edit/confirm_default_language: 67ee675555346c972ae1b5a630351645
environments/surveys/edit/confirm_survey_changes: 98228b8f52285a04df33ee59b9b04648
environments/surveys/edit/connect_formbricks_and_launch_surveys: 5c77647d9e40d68117386b8ca4826cd7
environments/surveys/edit/contact_fields: 0d4e3f4d2eb3481aabe3ac60a692fa74
environments/surveys/edit/contains: 41c8c25407527a5336404313f4c8d650
environments/surveys/edit/continue_to_settings: b9853a7eedb3ae295088268fe5a44824
@@ -1378,6 +1374,7 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
@@ -1483,7 +1480,8 @@ checksums:
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
@@ -1522,7 +1520,7 @@ checksums:
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
environments/surveys/edit/limit_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
environments/surveys/edit/limit_upload_file_size_to_mb: 6f1e25f7488c195d55e1a0cedb6ee587
environments/surveys/edit/limit_upload_file_size_to: 949c48d25ae45259cc19464e95752d29
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
environments/surveys/edit/list: 94f13e7ef909a4de9db7abaa1f9f0b61
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
@@ -1537,8 +1535,7 @@ checksums:
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
environments/surveys/edit/max_file_size_limit_is_mb: fb130d0c2af0004264de854c2281045f
environments/surveys/edit/max_file_size_limit_is_mb_upgrade: 23dab3e89549095a3b6f9f3bba3c9524
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
environments/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
@@ -1650,6 +1647,8 @@ checksums:
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
environments/surveys/edit/scale: 5f55a30a5bdf8f331b56bad9c073473c
environments/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
environments/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
environments/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
environments/surveys/edit/select_field: 45665a44f7d5707506364f17f28db3bf
environments/surveys/edit/select_or_type_value: a99c307b2cc3f9f6f893babd546d7296
environments/surveys/edit/select_ordering: c8f632a17fe78d8b7f87e82df9351ff9
@@ -1657,7 +1656,7 @@ checksums:
environments/surveys/edit/select_type: fa373e47f55ff081982844a853be3a88
environments/surveys/edit/send_survey_to_audience_who_match: 8bc5660659f6e28cc19b1961897e9878
environments/surveys/edit/send_your_respondents_to_a_page_of_your_choice: 9b77b1afe9a81bdbbb55ae323c5a3175
environments/surveys/edit/set_global_placement_in_look_feel_settings_hint: 18e030590220d3d8626c8f9ddb4dd855
environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929
environments/surveys/edit/settings_saved_successfully: 7f6833d9079e404fb3a5b0aa51fdcf17
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
@@ -1667,7 +1666,7 @@ checksums:
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
environments/surveys/edit/show_survey_maximum_of_n_times: 8f298b567cdd1c31db9ad56fc0c985aa
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
@@ -1703,6 +1702,8 @@ checksums:
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
environments/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
environments/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
environments/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
environments/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
@@ -1777,9 +1778,8 @@ checksums:
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
environments/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
environments/surveys/edit/wait_n_days_before_showing_this_survey_again: 1fbe83d8aaf59846d779e4e23d7d168b
environments/surveys/edit/wait_n_seconds_before_showing_the_survey: 8ff15e96a2f4ef23117bcd6da1cabdae
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
@@ -1812,7 +1812,6 @@ checksums:
environments/surveys/responses/completed: 2dca9d2e4c0fe669801112a66d679f6d
environments/surveys/responses/country: 73581fc33a1e83e6a56db73558e7b5c6
environments/surveys/responses/decrement_quotas: 9ef63d3e9214c508eb040eb41c25a5c4
environments/surveys/responses/delete_response: d86cb6fe4af953cde58f12092962bca4
environments/surveys/responses/delete_response_confirmation: 83a43954ca60c8ef30b9683253a6f5ea
environments/surveys/responses/delete_response_quotas: 6719c90d8019000ca0a74586fb3b6a65
environments/surveys/responses/device: c009f849d689c745de9e38ad17644c7a
@@ -1837,10 +1836,8 @@ checksums:
environments/surveys/responses/this_response_is_in_progress: 7d785fcb597ea30466467084fd474904
environments/surveys/responses/zip_post_code: ab7dc45bd5f9e37930586e2db17e4304
environments/surveys/search_by_survey_name: 44cf2e6f8ba43d233fb33939431eba99
environments/surveys/share/anonymous_links/custom_single_use_id_description: 994c76257cc373388a3870caf9d85dc7
environments/surveys/share/anonymous_links/custom_single_use_id_placeholder: e089b0f1838164b2ef2f74076477d8f2
environments/surveys/share/anonymous_links/custom_single_use_id_required: 36f60e76ebed5af11661ac7bbadd9a2b
environments/surveys/share/anonymous_links/custom_single_use_id_title: 0a1b97ed6fd82d9b0f9f43750d45fbe3
environments/surveys/share/anonymous_links/custom_single_use_id_description: 53c6d2b6cf597115b1f5a280ce7e9cab
environments/surveys/share/anonymous_links/custom_single_use_id_title: 9d708fe4ced64ddedd307fb61827cd03
environments/surveys/share/anonymous_links/custom_start_point: 4ea6552b37339d17e02f3bce8c7e4125
environments/surveys/share/anonymous_links/data_prefilling: 82f0e31e90f1f2ca31361df9893e117c
environments/surveys/share/anonymous_links/description: d13534a22f135420651ebfd218a4f01b
@@ -2100,7 +2097,6 @@ checksums:
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
environments/workspace/general/delete_workspace_confirmation_name: 79a461e6b63dd8c281d9ce1b43bc6f49
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 1b6c0597fddc5b6604e3a204402ed35e
environments/workspace/general/delete_workspace_settings_description: 411ef100f167fc8fca64e833b6c0d030
environments/workspace/general/error_saving_workspace_information: e7b8022785619ef34de1fb1630b3c476
@@ -2290,13 +2286,11 @@ checksums:
organizations/workspaces/new/settings/workspace_settings_title: a4ec3549507071e1f7d3c834b019fcce
s/check_inbox_or_spam: c48ac1f7b76052881bb3b6d10615152d
s/completed: 98a9cd97b409933edf1991e7d022bea9
s/completed_heading: 0e4bbce9985f25eb673d9a054c8d5334
s/create_your_own: 27976ec69029d6dd52d146a9b5765bc6
s/enter_pin: 90bccdc2e51ee2550842287d1f02c999
s/just_curious: d5e5c40e97fcfdab563707ab0de10862
s/link_invalid: bd63a73fa5eecad2dfd0687cfed02114
s/paused: 65ec150837ca033f5a0fb5d4482e0e4b
s/paused_heading: edb1f7b7219e1c9b7aa67159090d6991
s/please_try_again_with_the_original_link: ff903c908d199ae654436972dc0576f9
s/preview_survey_questions: 2cbdd526f6652d5ee0337e23477d3396
s/question_preview: 9d8fbc0150fc10ba851beba2d4f4d9f3
@@ -2320,7 +2314,7 @@ checksums:
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
setup/invite/failed_to_invite: dc5ce0dcdf0df978f1102b729dc15584
setup/invite/invitation_sent_to_email: 0c70bfd61508aea1d447e1a9dcb8bb8a
setup/invite/invitation_sent_to: 5a79e59f2048ee2f8b660715dcb98191
setup/invite/invite_your_organization_members: 98de00cb78b11297679f1565e0c24517
setup/invite/life_s_no_fun_alone: e4e080c6957a10bf218fbd1b5d8cdecc
setup/invite/skip: b7f28dfa2f58b80b149bb82b392d0291
+2 -151
View File
@@ -1,16 +1,12 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
createActionClass,
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClasses,
updateActionClass,
} from "./service";
vi.mock("@formbricks/database", () => ({
@@ -20,8 +16,6 @@ vi.mock("@formbricks/database", () => ({
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
@@ -184,147 +178,4 @@ describe("ActionClass Service", () => {
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
});
});
describe("createActionClass", () => {
const codeInput: TActionClassInput = {
name: "Code Action",
description: "desc",
type: "code",
key: "code-action-key",
environmentId: "env-create",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should create and return the action class", async () => {
const created: TActionClass = {
id: "id-create",
createdAt: new Date(),
updatedAt: new Date(),
name: codeInput.name,
description: codeInput.description ?? null,
type: "code",
key: codeInput.type === "code" ? codeInput.key : null,
noCodeConfig: null,
environmentId: codeInput.environmentId,
};
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
const result = await createActionClass(codeInput.environmentId, codeInput);
expect(result).toEqual(created);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Action with name ${codeInput.name} already exists`
);
});
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: undefined }
)
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for non-P2002 errors", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Database error when creating an action for environment ${codeInput.environmentId}`
);
});
});
describe("updateActionClass", () => {
const updateInput: Partial<TActionClassInput> = {
name: "Renamed Action",
description: "updated desc",
type: "code",
key: "renamed-key",
environmentId: "env-update",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should update and return the action class", async () => {
const updated = {
id: "id-update",
createdAt: new Date(),
updatedAt: new Date(),
name: updateInput.name,
description: updateInput.description ?? null,
type: "code" as const,
key: "renamed-key",
noCodeConfig: null,
environmentId: updateInput.environmentId,
surveyTriggers: [],
};
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
expect(result).toEqual(updated);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
`Action with name ${updateInput.name} already exists`
);
});
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "test",
})
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
DatabaseError
);
});
test("should rethrow unknown errors", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
"boom"
);
});
});
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -135,7 +135,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new UniqueConstraintError(
throw new DatabaseError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
@@ -185,7 +185,7 @@ export const updateActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new UniqueConstraintError(
throw new DatabaseError(
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
+2 -6
View File
@@ -10,7 +10,8 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
export const WEBAPP_URL = env.WEBAPP_URL?.trim() || "http://localhost:3000";
export const WEBAPP_URL =
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
// encryption keys
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
@@ -25,7 +26,6 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
@@ -33,7 +33,6 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
@@ -154,9 +153,6 @@ export const DEBUG = env.DEBUG === "1";
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const ENTERPRISE_LICENSE_REQUEST_FORM_URL =
"https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=ce";
export const REDIS_URL = env.REDIS_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
+3 -16
View File
@@ -4,7 +4,7 @@ import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
@@ -18,31 +18,18 @@ export const selectDisplay = {
export const getDisplayCountBySurveyId = reactCache(
async (surveyId: string, filters?: TDisplayFilters): Promise<number> => {
validateInputs([surveyId, ZId], [filters, ZDisplayFilters.optional()]);
if (filters?.responseIds?.length === 0) {
return 0;
}
validateInputs([surveyId, ZId]);
try {
const displayCount = await prisma.display.count({
where: {
surveyId,
surveyId: surveyId,
...(filters?.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
...(filters?.responseIds && {
response: {
is: {
id: {
in: filters.responseIds,
},
},
},
}),
},
});
return displayCount;
@@ -4,14 +4,9 @@ import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
getDisplayCountBySurveyId,
getDisplaysByContactId,
getDisplaysBySurveyIdWithContact,
} from "../service";
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
const mockDisplaysForContact = [
{
@@ -50,74 +45,6 @@ const mockDisplaysWithContact = [
},
];
describe("getDisplayCountBySurveyId", () => {
describe("Happy Path", () => {
test("counts displays by surveyId", async () => {
vi.mocked(prisma.display.count).mockResolvedValue(5);
const result = await getDisplayCountBySurveyId(mockSurveyId);
expect(result).toBe(5);
expect(prisma.display.count).toHaveBeenCalledWith({
where: {
surveyId: mockSurveyId,
},
});
});
test("combines createdAt and responseIds filters", async () => {
const createdAt = {
min: new Date("2024-01-01T00:00:00.000Z"),
max: new Date("2024-01-31T23:59:59.999Z"),
};
vi.mocked(prisma.display.count).mockResolvedValue(2);
const result = await getDisplayCountBySurveyId(mockSurveyId, {
createdAt,
responseIds: mockResponseIds,
});
expect(result).toBe(2);
expect(prisma.display.count).toHaveBeenCalledWith({
where: {
surveyId: mockSurveyId,
createdAt: {
gte: createdAt.min,
lte: createdAt.max,
},
response: {
is: {
id: {
in: mockResponseIds,
},
},
},
},
});
});
test("returns 0 without querying when responseIds filter is empty", async () => {
const result = await getDisplayCountBySurveyId(mockSurveyId, { responseIds: [] });
expect(result).toBe(0);
expect(prisma.display.count).not.toHaveBeenCalled();
});
});
describe("Sad Path", () => {
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.count).mockRejectedValue(errToThrow);
await expect(getDisplayCountBySurveyId(mockSurveyId)).rejects.toThrow(DatabaseError);
});
});
});
describe("getDisplaysByContactId", () => {
describe("Happy Path", () => {
test("returns displays for a contact ordered by createdAt desc", async () => {
@@ -32,7 +32,6 @@ beforeEach(() => {
id: mockSurveyId,
name: "Test Survey",
environmentId: mockEnvironmentId,
status: "inProgress",
} as any);
});
+2 -4
View File
@@ -123,7 +123,6 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
@@ -137,7 +136,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
@@ -235,6 +233,7 @@ const parsedEnv = createEnv({
TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
@@ -268,7 +267,6 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
@@ -282,7 +280,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
@@ -353,6 +350,7 @@ const parsedEnv = createEnv({
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
NODE_ENV: process.env.NODE_ENV,

Some files were not shown because too many files have changed in this diff Show More