Compare commits

...

28 Commits

Author SHA1 Message Date
Matthias Nannt
b5610c8128 fix: remove vulnerable libraries from Dockerimage 2025-05-05 23:48:35 +02:00
Matti Nannt
3b1cddb9ce chore: upgrade npm dependencies in apps/web (#5669) 2025-05-05 23:44:14 +02:00
Matti Nannt
bd22aaaa86 chore: move js sdk and demo app to its own repository (#5668) 2025-05-05 21:03:54 +02:00
Piyush Gupta
e0e42d2eed fix: adds support for default_team_id env var (#5046)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 18:40:19 +00:00
Matti Nannt
616210f1bf chore: update outdated dependencies (#5666) 2025-05-05 19:34:56 +02:00
Matti Nannt
ff2e7f6cc7 chore: make microsoft login available without tenant (#5665) 2025-05-05 19:18:07 +02:00
Jakob Schott
d1ce037f7d feat: disable productionbrowsersourcemaps (#5663) 2025-05-05 16:55:05 +00:00
Matti Nannt
91f87f4b7b chore: upgrade prisma to 6.7.0 (#5664) 2025-05-05 18:38:59 +02:00
Dhruwang Jariwala
61657b9f9a chore: add char limit to other option (#5382)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 14:56:54 +00:00
Dhruwang Jariwala
476d032642 fix: regex DoS issues (#5520)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 12:11:51 +00:00
Johannes
7538e570c5 chore: enforce cookie options for more security (#5618) 2025-05-05 12:09:35 +00:00
Jakob Schott
66fcf4b79b fix: changed unnecessary map to forEach (#5490) 2025-05-05 14:12:34 +02:00
Dhruwang Jariwala
21371b1815 fix: weak cryptography security hotspot (#5600) 2025-05-05 11:41:56 +00:00
Harsh Bhat
a53c13d6ed docs: add enterprise features listed under a subpaage (#5594)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-05 08:00:47 +00:00
dependabot[bot]
1a0c6e72b2 chore(deps): bump actions/dependency-review-action from 4.5.0 to 4.6.0 (#5270)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 10:08:58 +02:00
dependabot[bot]
ba7c8b79b1 chore(deps): bump actions/checkout from 2.7.0 to 4.2.2 (#5273)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-05 10:08:26 +02:00
dependabot[bot]
d7b504eed0 chore(deps): bump step-security/harden-runner from 2.11.0 to 2.12.0 (#5559)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 10:00:21 +02:00
dependabot[bot]
a1df10eb09 chore(deps): bump sigstore/cosign-installer from 3.5.0 to 3.8.2 (#5560)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 09:55:05 +02:00
victorvhs017
92be409d4f chore: add tests to api V1 - part 2 (#5605) 2025-05-05 05:55:18 +00:00
victorvhs017
665c7c6bf1 chore: add tests to api V1 (#5593)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-05 05:38:16 +00:00
victorvhs017
6c2ff7ee08 chore: add tests to survey editor components - part 3 (#5587)
Co-authored-by: use-tusk[bot] <144006087+use-tusk[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-05 04:43:37 +00:00
victorvhs017
295a1bf402 chore: add tests to survey editor components - part 2 (#5575)
Co-authored-by: use-tusk[bot] <144006087+use-tusk[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-02 14:01:02 +00:00
Piyush Gupta
3e6f558b08 fix: recaptcha feature bugs (#5599) 2025-05-02 07:11:51 +00:00
Dhruwang Jariwala
aad5a59e82 fix: removed dynamic translation key (#5527)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-02 06:05:23 +00:00
victorvhs017
36d02480b2 chore: add tests to survey editor components (#5557)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-02 05:05:24 +00:00
Piyush Gupta
99454ac57b feat: add recaptcha v3 support to surveys (#5500)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-01 17:22:28 +00:00
Dhruwang Jariwala
e2915f878e chore: used ";" instead of "," for multi select response (#5596) 2025-05-01 06:23:01 +00:00
Dhruwang Jariwala
710a813e9b feat: added option 6 to rating (#5595) 2025-04-30 23:24:06 -07:00
290 changed files with 18576 additions and 10277 deletions

View File

@@ -93,10 +93,6 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
# Signup. Disable the ability for new users to create an account.
# Note: This variable is only available to the SaaS setup of Formbricks Cloud. Signup is disable by default for self-hosting.
# SIGNUP_DISABLED=1
# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1
@@ -120,6 +116,10 @@ IMPRINT_ADDRESS=
# TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Google reCAPTCHA v3 keys
RECAPTCHA_SITE_KEY=
RECAPTCHA_SECRET_KEY=
# Configure Github Login
GITHUB_ID=
GITHUB_SECRET=
@@ -154,10 +154,6 @@ NOTION_OAUTH_CLIENT_SECRET=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=
GOOGLE_SHEETS_CLIENT_SECRET=
@@ -176,8 +172,9 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
# Send new users to Brevo
# BREVO_API_KEY=
@@ -220,4 +217,4 @@ UNKEY_ROOT_KEY=
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT
# DISABLE_USER_MANAGEMENT=1

View File

@@ -2,6 +2,7 @@
When generating test files inside the "/app/web" path, follow these rules:
- You are an experienced senior software engineer
- Use vitest
- Ensure 100% code coverage
- Add as few comments as possible
@@ -13,6 +14,7 @@ When generating test files inside the "/app/web" path, follow these rules:
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
- When mocking data check if the properties added are part of the type of the object being mocked. Don't add properties that are not part of the type.
If it's a test for a ".tsx" file, follow these extra instructions:
@@ -22,6 +24,7 @@ afterEach(() => {
cleanup();
});
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
- You don't need to mock @tolgee/react

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -13,11 +13,11 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Build & Cache Web Binaries

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -17,11 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0

View File

@@ -44,7 +44,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3

View File

@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v3
uses: actions/checkout@v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

View File

@@ -46,11 +46,11 @@ jobs:
--health-retries=5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -51,7 +51,7 @@ jobs:
statuses: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0
with:
egress-policy: audit
- name: fail if conditional jobs failed

View File

@@ -31,12 +31,12 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -45,7 +45,7 @@ jobs:
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action

View File

@@ -38,12 +38,12 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Release Tag
id: extract_release_tag
@@ -65,7 +65,7 @@ jobs:
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action

View File

@@ -19,7 +19,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -35,12 +35,12 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -26,7 +26,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -14,11 +14,11 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -18,7 +18,7 @@ jobs:
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -1,5 +0,0 @@
NEXT_PUBLIC_FORMBRICKS_API_HOST=http://localhost:3000
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=YOUR_ENVIRONMENT_ID
# Copy the environment ID for the URL of your Formbricks App and
# paste it above to connect your Formbricks App with the Demo App.

View File

@@ -1,7 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/next.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

36
apps/demo/.gitignore vendored
View File

@@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,13 +0,0 @@
import { Sidebar } from "./sidebar";
export function LayoutApp({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<div className="min-h-full">
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<Sidebar />
</div>
<div className="flex flex-1 flex-col lg:pl-64">{children}</div>
</div>
);
}

View File

@@ -1,65 +0,0 @@
import {
ClockIcon,
CogIcon,
CreditCardIcon,
FileBarChartIcon,
HelpCircleIcon,
HomeIcon,
ScaleIcon,
ShieldCheckIcon,
UsersIcon,
} from "lucide-react";
import { classNames } from "../lib/utils";
const navigation = [
{ name: "Home", href: "#", icon: HomeIcon, current: true },
{ name: "History", href: "#", icon: ClockIcon, current: false },
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
{ name: "Recipients", href: "#", icon: UsersIcon, current: false },
{ name: "Reports", href: "#", icon: FileBarChartIcon, current: false },
];
const secondaryNavigation = [
{ name: "Settings", href: "#", icon: CogIcon },
{ name: "Help", href: "#", icon: HelpCircleIcon },
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
];
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
<div className="space-y-1 px-2">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
<div className="mt-6 pt-6">
<div className="space-y-1 px-2">
{secondaryNavigation.map((item) => (
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
</div>
</nav>
</div>
);
}

View File

@@ -1,23 +0,0 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

View File

@@ -1,3 +0,0 @@
export function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@@ -1,17 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "tailwindui.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;

View File

@@ -1,28 +0,0 @@
{
"name": "@formbricks/demo",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
"dev": "next dev -p 3002 --turbopack",
"go": "next dev -p 3002 --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*"
}
}

View File

@@ -1,20 +0,0 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import "../globals.css";
export default function App({ Component, pageProps }: AppProps): React.JSX.Element {
return (
<>
<Head>
<title>Demo App</title>
</Head>
{(!process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID ||
!process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) && (
<div className="w-full bg-red-500 p-3 text-center text-sm text-white">
Please set Formbricks environment variables in apps/demo/.env
</div>
)}
<Component {...pageProps} />
</>
);
}

View File

@@ -1,13 +0,0 @@
import { Head, Html, Main, NextScript } from "next/document";
export default function Document(): React.JSX.Element {
return (
<Html lang="en" className="h-full bg-slate-50">
<Head />
<body className="h-full">
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@@ -1,359 +0,0 @@
import Image from "next/image";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import formbricks from "@formbricks/js";
import fbsetup from "../public/fb-setup.png";
declare const window: Window;
export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userAttributes = {
"Attribute 1": "one",
"Attribute 2": "two",
"Attribute 3": "three",
};
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
useEffect(() => {
const initFormbricks = () => {
// enable Formbricks debug mode by adding formbricksDebug=true GET parameter
const addFormbricksDebugParam = (): void => {
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has("formbricksDebug")) {
urlParams.set("formbricksDebug", "true");
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
window.history.replaceState({}, "", newUrl);
}
};
addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
void formbricks.setup({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
});
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks.registerRouteChange;
router.events.on("routeChangeComplete", () => {
void handleRouteChange();
});
return () => {
router.events.off("routeChangeComplete", () => {
void handleRouteChange();
});
};
}
};
initFormbricks();
}, [router.events]);
return (
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
</div>
<button
type="button"
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => {
setDarkMode(!darkMode);
}}>
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white">
Set a user ID / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
the local state gets <strong>updated with the user state</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
type="button"
onClick={() => {
void formbricks.setUserId(userId);
}}>
Set user ID
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
No-Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
No Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Free");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Free&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Free&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Paid");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Paid&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Paid&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setEmail("test@web.com");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Email
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user email
</a>{" "}
&apos;test@web.com&apos;
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttributes(userAttributes);
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Multiple Attributes
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user attributes
</a>{" "}
to &apos;one&apos;, &apos;two&apos;, &apos;three&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setLanguage("de");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Language to &apos;de&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
language
</a>{" "}
to &apos;de&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.track("code");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.logout();
}}>
Logout
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button logs out the user and syncs the local state with Formbricks. (Only works if a
userId is set)
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

View File

@@ -1,5 +0,0 @@
{
"exclude": ["node_modules"],
"extends": "@formbricks/config-typescript/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

View File

@@ -11,13 +11,12 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.19",
"eslint-plugin-react-refresh": "0.4.20",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
@@ -27,14 +26,13 @@
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.5"
"vite": "6.3.5"
}
}

View File

@@ -79,7 +79,6 @@ RUN npm install -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -129,8 +128,7 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
@@ -156,12 +154,6 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
CMD (cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js

View File

@@ -1,6 +1,5 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
@@ -125,7 +124,6 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -1,81 +0,0 @@
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
// Mock next/navigation hooks.
vi.mock("next/navigation", () => ({
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
}));
// Mock the Formbricks SDK module.
vi.mock("@formbricks/js", () => ({
__esModule: true,
default: {
setup: vi.fn(),
setUserId: vi.fn(),
setEmail: vi.fn(),
registerRouteChange: vi.fn(),
},
}));
describe("FormbricksClient", () => {
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
environmentId: "env-test",
appUrl: "https://api.test.com",
});
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
// And the second effect should always register the route change when Formbricks is enabled.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();
expect(mockSetUserId).not.toHaveBeenCalled();
expect(mockSetEmail).not.toHaveBeenCalled();
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
});

View File

@@ -1,44 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js";
interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.setup({
environmentId: formbricksEnvironmentId ?? "",
appUrl: formbricksApiHost ?? "",
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
useEffect(() => {
if (formbricksEnabled) {
formbricks.registerRouteChange();
}
}, [pathname, searchParams, formbricksEnabled]);
return null;
};

View File

@@ -2,7 +2,6 @@
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -265,7 +264,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -392,7 +391,6 @@ export const MainNavigation = ({
onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
await formbricksLogout();
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -26,13 +26,6 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("../../components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
}));

View File

@@ -1,6 +1,5 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -37,7 +36,6 @@ export const DeleteAccount = ({
setOpen={setModalOpen}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
formbricksLogout={formbricksLogout}
organizationsWithSingleOwner={organizationsWithSingleOwner}
/>
<p className="text-sm text-slate-700">

View File

@@ -409,7 +409,7 @@ export const getQuestionSummary = async (
}
});
Object.entries(choiceCountMap).map(([label, count]) => {
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
value: label,
count,
@@ -508,7 +508,7 @@ export const getQuestionSummary = async (
}
});
Object.entries(choiceCountMap).map(([label, count]) => {
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: parseInt(label),
count,

View File

@@ -9,6 +9,7 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -123,6 +124,10 @@ export const updateSurveyAction = authenticatedActionClient
const { followUps } = parsedInput;
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}

View File

@@ -36,14 +36,10 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
}));
@@ -74,17 +70,5 @@ describe("(app) AppLayout", () => {
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
});
test("skips FormbricksClient if no user is present", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const element = await AppLayout({
children: <div data-testid="child-content">Hello from children</div>,
});
render(element);
expect(screen.queryByTestId("formbricks-client")).not.toBeInTheDocument();
});
});

View File

@@ -1,13 +1,5 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
IS_POSTHOG_CONFIGURED,
POSTHOG_API_HOST,
POSTHOG_API_KEY,
} from "@/lib/constants";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -38,15 +30,6 @@ const AppLayout = async ({ children }) => {
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
{user ? (
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}

View File

@@ -156,6 +156,7 @@ export const mockSurvey: TSurvey = {
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
recaptcha: null,
projectOverwrites: null,
styling: null,
surveyClosedMessage: null,

View File

@@ -0,0 +1,276 @@
import { convertResponseValue } from "@/lib/responses";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
TWeeklyEmailResponseData,
TWeeklySummaryEnvironmentData,
TWeeklySummarySurveyData,
} from "@formbricks/types/weekly-summary";
import { getNotificationResponse } from "./notificationResponse";
vi.mock("@/lib/responses", () => ({
convertResponseValue: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
describe("getNotificationResponse", () => {
afterEach(() => {
cleanup();
});
test("should return a notification response with calculated insights and survey data when provided with an environment containing multiple surveys", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(2);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(2);
expect(notificationResponse.insights.totalResponses).toBe(5);
expect(notificationResponse.insights.completionRate).toBe(60);
expect(notificationResponse.insights.numLiveSurvey).toBe(2);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
});
test("should calculate the correct completion rate and other insights when surveys have responses with varying statuses", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey3",
name: "Survey 3",
status: "inProgress",
questions: [
{
id: "question3",
headline: { default: "Question 3" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display3" }],
responses: [{ id: "response6", finished: false, data: { question3: "Answer 6" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(3);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(3);
expect(notificationResponse.insights.totalResponses).toBe(6);
expect(notificationResponse.insights.completionRate).toBe(50);
expect(notificationResponse.insights.numLiveSurvey).toBe(3);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
expect(notificationResponse.surveys[2].id).toBe("survey3");
expect(notificationResponse.surveys[2].name).toBe("Survey 3");
expect(notificationResponse.surveys[2].status).toBe("inProgress");
expect(notificationResponse.surveys[2].responseCount).toBe(1);
});
test("should return default insights and an empty surveys array when the environment contains no surveys", () => {
const mockEnvironment = {
id: "env1",
surveys: [],
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(0);
expect(notificationResponse.insights.totalCompletedResponses).toBe(0);
expect(notificationResponse.insights.totalDisplays).toBe(0);
expect(notificationResponse.insights.totalResponses).toBe(0);
expect(notificationResponse.insights.completionRate).toBe(0);
expect(notificationResponse.insights.numLiveSurvey).toBe(0);
});
test("should handle missing response data gracefully when a response doesn't contain data for a question ID", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: {} }, // Response missing data for question1
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
// Mock the convertResponseValue function to handle the missing data case
vi.mocked(convertResponseValue).mockReturnValue("");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys).toHaveLength(1);
expect(notificationResponse.surveys[0].responses).toHaveLength(1);
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("");
});
test("should handle unsupported question types gracefully", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "unsupported",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [{ id: "response1", finished: true, data: { question1: "Answer 1" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
vi.mocked(convertResponseValue).mockReturnValue("Unsupported Response");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("Unsupported Response");
});
});

View File

@@ -0,0 +1,48 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getOrganizationIds } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findMany: vi.fn(),
},
},
}));
describe("Organization", () => {
afterEach(() => {
cleanup();
});
test("getOrganizationIds should return an array of organization IDs when the database contains multiple organizations", async () => {
const mockOrganizations = [{ id: "org1" }, { id: "org2" }, { id: "org3" }];
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual(["org1", "org2", "org3"]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
test("getOrganizationIds should return an empty array when the database contains no organizations", async () => {
vi.mocked(prisma.organization.findMany).mockResolvedValue([]);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual([]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
});

View File

@@ -0,0 +1,570 @@
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getProjectsByOrganizationId } from "./project";
const mockProjects = [
{
id: "project1",
name: "Project 1",
environments: [
{
id: "env1",
type: "production",
surveys: [],
attributeKeys: [],
},
],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project1: true,
},
},
locale: "en",
},
},
],
},
},
];
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // Set to 6 days ago to be within the last 7 days
const mockProjectsWithNoEnvironments = [
{
id: "project3",
name: "Project 3",
environments: [],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project3: true,
},
},
locale: "en",
},
},
],
},
},
];
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findMany: vi.fn(),
},
},
}));
describe("Project Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("getProjectsByOrganizationId", () => {
test("retrieves projects with environments, surveys, and organization memberships for a valid organization ID", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles date calculations correctly across DST boundaries", async () => {
const mockDate = new Date(2024, 10, 3, 0, 0, 0); // November 3, 2024, 00:00:00 (example DST boundary)
const sevenDaysAgo = new Date(mockDate);
sevenDaysAgo.setDate(mockDate.getDate() - 7);
vi.useFakeTimers();
vi.setSystemTime(mockDate);
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
await getProjectsByOrganizationId(organizationId);
expect(prisma.project.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
organizationId: organizationId,
},
select: expect.objectContaining({
environments: expect.objectContaining({
select: expect.objectContaining({
surveys: expect.objectContaining({
where: expect.objectContaining({
NOT: expect.objectContaining({
AND: expect.arrayContaining([
expect.objectContaining({ status: "completed" }),
expect.objectContaining({
responses: expect.objectContaining({
none: expect.objectContaining({
createdAt: expect.objectContaining({
gte: sevenDaysAgo,
}),
}),
}),
}),
]),
}),
}),
}),
}),
}),
}),
})
);
vi.useRealTimers();
});
test("includes surveys with 'completed' status but responses within the last 7 days", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("returns an empty array when an invalid organization ID is provided", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce([]);
const invalidOrganizationId = "invalidOrgId";
const projects = await getProjectsByOrganizationId(invalidOrganizationId);
expect(projects).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: invalidOrganizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles projects with no environments", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjectsWithNoEnvironments);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjectsWithNoEnvironments);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
});
});

View File

@@ -0,0 +1,99 @@
import { cache } from "@/lib/cache";
import { TContact } from "@/modules/ee/contacts/types/contact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
// Mock cache\
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
const environmentId = "test-environment-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const contactMock: Partial<TContact> & {
attributes: { value: string; attributeKey: { key: string } }[];
} = {
id: contactId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
describe("getContactByUserId", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return contact if found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toEqual(contactMock);
});
test("should return null if contact not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toBeNull();
});
});

View File

@@ -0,0 +1,309 @@
import { cache } from "@/lib/cache";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSyncSurveys } from "./survey";
// Mock dependencies
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
anySurveyHasFilters: vi.fn(),
}));
vi.mock("@/lib/utils/datetime", () => ({
diffInDays: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
evaluateSegment: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findMany: vi.fn(),
},
response: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-env-id";
const contactId = "test-contact-id";
const contactAttributes = { userId: "user1", email: "test@example.com" };
const deviceType = "desktop";
const mockProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
environments: [],
recontactDays: 10,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
languages: [],
} as unknown as TProject;
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey 1",
environmentId: environmentId,
type: "app",
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
surveyClosedMessage: null,
singleUse: null,
styling: null,
pin: null,
resultShareKey: null,
displayLimit: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],
triggers: [],
languages: [],
variables: [],
hiddenFields: { enabled: false },
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
};
describe("getSyncSurveys", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
vi.mocked(evaluateSegment).mockResolvedValue(true);
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
});
afterEach(() => {
vi.resetAllMocks();
});
test("should throw error if product not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
"Product not found"
);
});
test("should return empty array if no surveys found", async () => {
vi.mocked(getSurveys).mockResolvedValue([]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should return empty array if no 'app' type surveys in progress", async () => {
const surveys: TSurvey[] = [
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should filter by displayOption 'displayOnce'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Already displayed
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displayMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]); // Already responded
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displaySome'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([
{ id: "d1", surveyId: "s1", contactId },
{ id: "d2", surveyId: "s1", contactId },
]); // Display limit reached
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Within limit
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
// Test with response already submitted
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]);
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result3).toEqual([]);
});
test("should not filter by displayOption 'respondMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]);
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
});
test("should filter by product recontactDays if survey recontactDays is null", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const displayDate = new Date();
vi.mocked(prisma.display.findMany).mockResolvedValue([
{ id: "d1", surveyId: "s2", contactId, createdAt: displayDate }, // Display for another survey
]);
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should return surveys if no segment filters exist", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
expect(evaluateSegment).not.toHaveBeenCalled();
});
test("should evaluate segment filters if they exist", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
// Case 1: Segment evaluation matches
vi.mocked(evaluateSegment).mockResolvedValue(true);
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result1).toEqual(surveys);
expect(evaluateSegment).toHaveBeenCalledWith(
{
attributes: contactAttributes,
deviceType,
environmentId,
contactId,
userId: contactAttributes.userId,
},
segment.filters
);
// Case 2: Segment evaluation does not match
vi.mocked(evaluateSegment).mockResolvedValue(false);
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual([]);
});
test("should handle Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(getSurveys).mockRejectedValue(prismaError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
DatabaseError
);
expect(logger.error).toHaveBeenCalledWith(prismaError);
});
test("should handle general errors", async () => {
const generalError = new Error("Something went wrong");
vi.mocked(getSurveys).mockRejectedValue(generalError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
generalError
);
});
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
// Let's assume the filter logic works correctly and test the intended path.
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]); // Expect empty array, not an error in this case.
});
});

View File

@@ -0,0 +1,247 @@
import { parseRecallInfo } from "@/lib/utils/recall";
import { describe, expect, test, vi } from "vitest";
import { TAttributes } from "@formbricks/types/attributes";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyEnding,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { replaceAttributeRecall } from "./utils";
vi.mock("@/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((text, attributes) => {
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
const match = text.match(recallPattern);
if (match && match[1]) {
const recallKey = match[1];
const attributeValue = attributes[recallKey];
if (attributeValue !== undefined) {
return text.replace(recallPattern, `parsed-${attributeValue}`);
}
}
return text; // Return original text if no match or attribute not found
}),
}));
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
],
triggers: [],
recontactDays: null,
displayLimit: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
variables: [],
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
displayOption: "displayOnce",
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
pin: null,
resultShareKey: null,
};
const attributes: TAttributes = {
name: "John Doe",
email: "john.doe@example.com",
plan: "premium",
};
describe("replaceAttributeRecall", () => {
test("should replace recall info in question headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!" },
subheader: { default: "Your email is recall:email" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
});
test("should replace recall info in welcome card headline", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome, recall:name!" },
html: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
});
test("should replace recall info in end screen headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
endings: [
{
type: "endScreen",
headline: { default: "Thank you, recall:name!" },
subheader: { default: "Your plan: recall:plan" },
buttonLabel: { default: "Finish" },
buttonLink: "https://example.com",
} as unknown as TSurveyEnding,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.endings[0].type).toBe("endScreen");
if (result.endings[0].type === "endScreen") {
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
}
});
test("should handle multiple languages", () => {
const surveyMultiLang: TSurvey = {
...baseSurvey,
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
],
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
required: true,
buttonLabel: { default: "Next", es: "Siguiente" },
placeholder: { default: "Type here...", es: "Escribe aquí..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyMultiLang, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
});
test("should not replace if recall key is not in attributes", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Your company: recall:company" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
});
test("should handle surveys with no recall information", async () => {
const surveyNoRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Just a regular question" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
html: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
endings: [
{
type: "endScreen",
headline: { default: "Thank you!" },
buttonLabel: { default: "Finish" },
} as unknown as TSurveyEnding,
],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyNoRecall, attributes);
expect(result).toEqual(surveyNoRecall); // Should be unchanged
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
const surveyEmpty: TSurvey = {
...baseSurvey,
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyEmpty, attributes);
expect(result).toEqual(surveyEmpty);
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
});

View File

@@ -0,0 +1,86 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { TJsEnvironmentStateActionClass } from "@formbricks/types/js";
import { getActionClassesForEnvironmentState } from "./actionClass";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const mockActionClasses: TJsEnvironmentStateActionClass[] = [
{
id: "action1",
type: "code",
name: "Code Action",
key: "code-action",
noCodeConfig: null,
},
{
id: "action2",
type: "noCode",
name: "No Code Action",
key: null,
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
},
];
describe("getActionClassesForEnvironmentState", () => {
test("should return action classes successfully", async () => {
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
const result = await getActionClassesForEnvironmentState(environmentId);
expect(result).toEqual(mockActionClasses);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // ZId is an object
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: {
id: true,
type: true,
name: true,
key: true,
noCodeConfig: true,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getActionClassesForEnvironmentState-${environmentId}`],
{ tags: [`environments-${environmentId}-actionClasses`] }
);
});
test("should throw DatabaseError on prisma error", async () => {
const mockError = new Error("Prisma error");
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(mockError);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(
`Database error when fetching actions for environment ${environmentId}`
);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.actionClass.findMany).toHaveBeenCalled();
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getActionClassesForEnvironmentState-${environmentId}`],
{ tags: [`environments-${environmentId}-actionClasses`] }
);
});
});

View File

@@ -0,0 +1,372 @@
import { cache } from "@/lib/cache";
import { getEnvironment } from "@/lib/environment/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getActionClassesForEnvironmentState } from "./actionClass";
import { getEnvironmentState } from "./environmentState";
import { getProjectForEnvironmentState } from "./project";
import { getSurveysForEnvironmentState } from "./survey";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/environment/service");
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/modules/ee/license-check/lib/utils");
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
update: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("./actionClass");
vi.mock("./project");
vi.mock("./survey");
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, // Default to false, override in specific tests
RECAPTCHA_SITE_KEY: "mock_recaptcha_site_key",
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
}));
const environmentId = "test-environment-id";
const mockEnvironment: TEnvironment = {
id: environmentId,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "test-project-id",
type: "production",
appSetupCompleted: true, // Default to true
};
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
stripeCustomerId: null,
period: "monthly",
limits: {
projects: 1,
monthly: {
responses: 100, // Default limit
miu: 1000,
},
},
periodStart: new Date(),
},
isAIEnabled: false,
};
const mockProject: TProject = {
id: "test-project-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
config: {
channel: "link",
industry: "eCommerce",
},
organizationId: mockOrganization.id,
styling: {
allowStyleOverwrite: false,
},
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
languages: [],
};
const mockSurveys: TSurvey[] = [
{
id: "survey-app-inProgress",
createdAt: new Date(),
updatedAt: new Date(),
name: "App Survey In Progress",
environmentId: environmentId,
type: "app",
status: "inProgress",
displayLimit: null,
endings: [],
followUps: [],
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
singleUse: null,
triggers: [],
languages: [],
pin: null,
resultShareKey: null,
segment: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false },
variables: [],
createdBy: null,
recaptcha: { enabled: false, threshold: 0.5 },
},
{
id: "survey-app-paused",
createdAt: new Date(),
updatedAt: new Date(),
name: "App Survey Paused",
environmentId: environmentId,
displayLimit: null,
endings: [],
followUps: [],
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
type: "app",
status: "paused",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
singleUse: null,
triggers: [],
languages: [],
pin: null,
resultShareKey: null,
segment: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false },
variables: [],
createdBy: null,
recaptcha: { enabled: false, threshold: 0.5 },
},
{
id: "survey-web-inProgress",
createdAt: new Date(),
updatedAt: new Date(),
name: "Web Survey In Progress",
environmentId: environmentId,
type: "link",
displayLimit: null,
endings: [],
followUps: [],
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
singleUse: null,
triggers: [],
languages: [],
pin: null,
resultShareKey: null,
segment: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false },
variables: [],
createdBy: null,
recaptcha: { enabled: false, threshold: 0.5 },
},
];
const mockActionClasses: TActionClass[] = [
{
id: "action-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 1",
description: null,
type: "code",
noCodeConfig: null,
environmentId: environmentId,
key: "action1",
},
];
describe("getEnvironmentState", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mock the cache implementation
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
// Default mocks for successful retrieval
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return the correct environment state", async () => {
const result = await getEnvironmentState(environmentId);
const expectedData: TJsEnvironmentState["data"] = {
recaptchaSiteKey: "mock_recaptcha_site_key",
surveys: [mockSurveys[0]], // Only app, inProgress survey
actionClasses: mockActionClasses,
project: mockProject,
};
expect(result.data).toEqual(expectedData);
expect(result.revalidateEnvironment).toBe(false);
expect(getEnvironment).toHaveBeenCalledWith(environmentId);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(getProjectForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(getSurveysForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(getActionClassesForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalled(); // Not cloud
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null);
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError if project not found", async () => {
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(null);
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should update environment and capture event if app setup not completed", async () => {
const incompleteEnv = { ...mockEnvironment, appSetupCompleted: false };
vi.mocked(getEnvironment).mockResolvedValue(incompleteEnv);
const result = await getEnvironmentState(environmentId);
expect(prisma.environment.update).toHaveBeenCalledWith({
where: { id: environmentId },
data: { appSetupCompleted: true },
});
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.revalidateEnvironment).toBe(true);
});
test("should return empty surveys if monthly response limit reached (Cloud)", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); // Exactly at limit
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: mockOrganization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: mockOrganization.billing.limits.monthly.responses,
},
},
});
});
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(99); // Below limit
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([mockSurveys[0]]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should handle error when sending Posthog limit reached event", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("Posthog failed");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
const result = await getEnvironmentState(environmentId);
expect(result.data.recaptchaSiteKey).toBe("mock_recaptcha_site_key");
});
test("should filter surveys correctly (only app type and inProgress status)", async () => {
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toHaveLength(1);
expect(result.data.surveys[0].id).toBe("survey-app-inProgress");
});
});

View File

@@ -1,6 +1,6 @@
import { actionClassCache } from "@/lib/actionClass/cache";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { environmentCache } from "@/lib/environment/cache";
import { getEnvironment } from "@/lib/environment/service";
import { organizationCache } from "@/lib/organization/cache";
@@ -107,6 +107,7 @@ export const getEnvironmentState = async (
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
actionClasses,
project: project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
};
return {

View File

@@ -0,0 +1,120 @@
import { cache } from "@/lib/cache";
import { projectCache } from "@/lib/project/cache";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TJsEnvironmentStateProject } from "@formbricks/types/js";
import { getProjectForEnvironmentState } from "./project";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/project/cache");
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate"); // Mock validateInputs if needed, though it's often tested elsewhere
const environmentId = "test-environment-id";
const mockProject: TJsEnvironmentStateProject = {
id: "test-project-id",
recontactDays: 30,
clickOutsideClose: true,
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
};
describe("getProjectForEnvironmentState", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mock cache implementation
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
// Mock projectCache tags
vi.mocked(projectCache.tag.byEnvironmentId).mockReturnValue(`project-env-${environmentId}`);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return project state successfully", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
const result = await getProjectForEnvironmentState(environmentId);
expect(result).toEqual(mockProject);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: {
id: true,
recontactDays: true,
clickOutsideClose: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
},
});
expect(cache).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getProjectForEnvironmentState-${environmentId}`],
{
tags: [`project-env-${environmentId}`],
}
);
});
test("should return null if project not found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
const result = await getProjectForEnvironmentState(environmentId);
expect(result).toBeNull();
expect(prisma.project.findFirst).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw DatabaseError on PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2001",
clientVersion: "test",
});
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting project for environment state");
expect(cache).toHaveBeenCalledTimes(1);
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Something went wrong");
vi.mocked(prisma.project.findFirst).mockRejectedValue(unknownError);
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log unknown errors here
expect(cache).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,143 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { getSurveysForEnvironmentState } from "./survey";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/survey/lib/utils");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-environment-id";
const mockPrismaSurvey = {
id: "survey-1",
welcomeCard: { enabled: false },
name: "Test Survey",
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
status: "inProgress",
recaptcha: null,
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
};
const mockTransformedSurvey: TJsEnvironmentStateSurvey = {
id: "survey-1",
welcomeCard: { enabled: false } as TJsEnvironmentStateSurvey["welcomeCard"],
name: "Test Survey",
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
status: "inProgress",
recaptcha: null,
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
};
describe("getSurveysForEnvironmentState", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(validateInputs).mockReturnValue([environmentId]); // Assume validation passes
vi.mocked(transformPrismaSurvey).mockReturnValue(mockTransformedSurvey);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return transformed surveys on successful fetch", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurvey]);
const result = await getSurveysForEnvironmentState(environmentId);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object), // Check if select is called, specific fields are in the original code
});
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]);
expect(logger.error).not.toHaveBeenCalled();
});
test("should return an empty array if no surveys are found", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
const result = await getSurveysForEnvironmentState(environmentId);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object),
});
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys for environment state");
});
test("should rethrow unknown errors", async () => {
const unknownError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError);
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
expect(logger.error).not.toHaveBeenCalled();
});
});

View File

@@ -49,6 +49,7 @@ export const getSurveysForEnvironmentState = reactCache(
autoClose: true,
styling: true,
status: true,
recaptcha: true,
segment: {
include: {
surveys: {

View File

@@ -2,8 +2,9 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { updateResponse } from "@/lib/response/service";
import { getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -48,10 +49,9 @@ export const PUT = async (
);
}
// update response
let response;
try {
response = await updateResponse(responseId, inputValidation.data);
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
@@ -66,27 +66,64 @@ export const PUT = async (
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(response.data, survey.questions)) {
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response,
response: updatedResponse,
});
if (response.finished) {
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response,
response: updatedResponse,
});
}
return responses.successResponse({}, true);

View File

@@ -0,0 +1,160 @@
import { cache } from "@/lib/cache";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getContact, getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
}));
// Mock cache module
vi.mock("@/lib/cache");
// Mock react cache
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock react's cache to just return the function
};
});
const mockContactId = "test-contact-id";
const mockEnvironmentId = "test-env-id";
const mockUserId = "test-user-id";
describe("Contact API Lib", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe("getContact", () => {
test("should return contact if found", async () => {
const mockContactData = { id: mockContactId };
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData);
const contact = await getContact(mockContactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
select: { id: true },
});
expect(contact).toEqual(mockContactData);
});
test("should return null if contact not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const contact = await getContact(mockContactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
select: { id: true },
});
expect(contact).toBeNull();
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError);
await expect(getContact(mockContactId)).rejects.toThrow(DatabaseError);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
select: { id: true },
});
});
});
describe("getContactByUserId", () => {
test("should return contact with formatted attributes if found", async () => {
const mockContactData = {
id: mockContactId,
attributes: [
{ attributeKey: { key: "userId" }, value: mockUserId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId: mockEnvironmentId,
},
value: mockUserId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toEqual({
id: mockContactId,
attributes: {
userId: mockUserId,
email: "test@example.com",
},
});
});
test("should return null if contact not found by userId", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId: mockEnvironmentId,
},
value: mockUserId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toBeNull();
});
});
});

View File

@@ -0,0 +1,201 @@
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseInput } from "@formbricks/types/responses";
import { createResponse } from "./response";
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
}));
vi.mock("@/lib/organization/service", () => ({
getMonthlyOrganizationResponseCount: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc),
}));
vi.mock("@/lib/responseNote/cache", () => ({
responseNoteCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
create: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("./contact", () => ({
getContactByUserId: vi.fn(),
}));
const environmentId = "test-environment-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
const responseId = "test-response-id";
const mockOrganization = {
id: organizationId,
name: "Test Org",
billing: {
limits: { monthly: { responses: 100 } },
plan: "free",
},
};
const mockResponseInput: TResponseInput = {
environmentId,
surveyId,
userId: null,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
};
const mockResponsePrisma = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: null,
displayId: null,
tags: [],
notes: [],
};
describe("createResponse", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc);
});
afterEach(() => {
mockIsFormbricksCloud = false;
});
test("should handle finished response and calculate TTC", async () => {
const finishedInput = { ...mockResponseInput, finished: true };
await createResponse(finishedInput);
expect(calculateTtcTotal).toHaveBeenCalledWith(mockResponseInput.ttc);
expect(prisma.response.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ finished: true }),
})
);
});
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
});

View File

@@ -0,0 +1,103 @@
import { getUploadSignedUrl } from "@/lib/storage/service";
import { afterEach, describe, expect, test, vi } from "vitest";
import { uploadPrivateFile } from "./uploadPrivateFile";
vi.mock("@/lib/storage/service", () => ({
getUploadSignedUrl: vi.fn(),
}));
describe("uploadPrivateFile", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return a success response with signed URL details when getUploadSignedUrl successfully generates a signed URL", async () => {
const mockSignedUrlResponse = {
signedUrl: "mocked-signed-url",
presignedFields: { field1: "value1" },
fileUrl: "mocked-file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
expect(resultData).toEqual({
data: mockSignedUrlResponse,
});
});
test("should return a success response when isBiggerFileUploadAllowed is true and getUploadSignedUrl successfully generates a signed URL", async () => {
const mockSignedUrlResponse = {
signedUrl: "mocked-signed-url",
presignedFields: { field1: "value1" },
fileUrl: "mocked-file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const isBiggerFileUploadAllowed = true;
const result = await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(
fileName,
environmentId,
fileType,
"private",
isBiggerFileUploadAllowed
);
expect(resultData).toEqual({
data: mockSignedUrlResponse,
});
});
test("should return an internal server error response when getUploadSignedUrl throws an error", async () => {
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("S3 unavailable"));
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
expect(result.status).toBe(500);
const resultData = await result.json();
expect(resultData).toEqual({
code: "internal_server_error",
details: {},
message: "Internal server error",
});
});
test("should return an internal server error response when fileName has no extension", async () => {
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("File extension not found"));
const fileName = "test-file";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
expect(result.status).toBe(500);
expect(resultData).toEqual({
code: "internal_server_error",
details: {},
message: "Internal server error",
});
});
});

View File

@@ -0,0 +1,62 @@
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { mockUser } from "@/modules/auth/lib/mock-data";
import { cleanup } from "@testing-library/react";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
describe("getSessionUser", () => {
afterEach(() => {
cleanup();
});
test("should return the user object when valid req and res are provided", async () => {
const mockReq = {} as NextApiRequest;
const mockRes = {} as NextApiResponse;
vi.mocked(getServerSession).mockResolvedValue({ user: mockUser });
const user = await getSessionUser(mockReq, mockRes);
expect(user).toEqual(mockUser);
expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions);
});
test("should return the user object when neither req nor res are provided", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: mockUser });
const user = await getSessionUser();
expect(user).toEqual(mockUser);
expect(getServerSession).toHaveBeenCalledWith(authOptions);
});
test("should return undefined if no session exists", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const user = await getSessionUser();
expect(user).toBeUndefined();
});
test("should return null when session exists and user property is null", async () => {
const mockReq = {} as NextApiRequest;
const mockRes = {} as NextApiResponse;
vi.mocked(getServerSession).mockResolvedValue({ user: null });
const user = await getSessionUser(mockReq, mockRes);
expect(user).toBeNull();
expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions);
});
});

View File

@@ -0,0 +1,121 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/cache");
const environmentId = "test-env-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const mockContactDbData = {
id: contactId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "plan" }, value: "premium" },
],
};
const expectedContactAttributes: TContactAttributes = {
userId: userId,
email: "test@example.com",
plan: "premium",
};
describe("getContactByUserId", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should return contact with attributes when found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toEqual({
id: contactId,
attributes: expectedContactAttributes,
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
{
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
}
);
});
test("should return null when contact is not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toBeNull();
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
{
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
}
);
});
});

View File

@@ -0,0 +1,347 @@
import { cache } from "@/lib/cache";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { responseCache } from "@/lib/response/cache";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { responseNoteCache } from "@/lib/responseNote/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { getContactByUserId } from "./contact";
import { createResponse, getResponsesByEnvironmentIds } from "./response";
// Mock Data
const environmentId = "test-environment-id";
const organizationId = "test-organization-id";
const mockUserId = "test-user-id";
const surveyId = "test-survey-id";
const displayId = "test-display-id";
const responseId = "test-response-id";
const mockOrganization = {
id: organizationId,
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "free", limits: { monthly: { responses: null } } } as any, // Default no limit
} as unknown as Organization;
const mockResponseInput: TResponseInput = {
environmentId,
surveyId,
displayId,
finished: true,
data: { q1: "answer1" },
meta: { userAgent: { browser: "test-browser" } },
ttc: { q1: 5 },
language: "en",
};
const mockResponseInputWithUserId: TResponseInput = {
...mockResponseInput,
userId: mockUserId,
};
// Prisma response structure (simplified)
const mockResponsePrisma = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId,
finished: true,
endingId: null,
data: { q1: "answer1" },
meta: { userAgent: { browser: "test-browser" } },
ttc: { q1: 5, total: 10 }, // Assume calculateTtcTotal adds 'total'
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
displayId,
contact: null, // Prisma relation
tags: [], // Prisma relation
notes: [], // Prisma relation
} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed
const mockResponse: TResponse = {
id: responseId,
createdAt: mockResponsePrisma.createdAt,
updatedAt: mockResponsePrisma.updatedAt,
surveyId,
finished: true,
endingId: null,
data: { q1: "answer1" },
meta: { userAgent: { browser: "test-browser" } },
ttc: { q1: 5, total: 10 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
displayId,
contact: null, // Transformed structure
tags: [], // Transformed structure
notes: [], // Transformed structure
};
const mockEnvironmentIds = [environmentId, "env-2"];
const mockLimit = 10;
const mockOffset = 5;
const mockResponsesPrisma = [mockResponsePrisma, { ...mockResponsePrisma, id: "response-2" }];
const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response-2" }];
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/cache");
vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/responseNote/cache");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
create: vi.fn(),
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger");
vi.mock("./contact");
describe("Response Lib Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
// No need to mock IS_FORMBRICKS_CLOUD here anymore unless specifically changing it from the default
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
describe("createResponse", () => {
test("should create a response successfully with userId", async () => {
const mockContact = { id: "contact1", attributes: { userId: mockUserId } };
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue({
...mockResponsePrisma,
});
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
const response = await createResponse(mockResponseInputWithUserId);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId);
expect(prisma.response.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
contact: { connect: { id: mockContact.id } },
contactAttributes: mockContact.attributes,
}),
})
);
expect(responseCache.revalidate).toHaveBeenCalledWith(
expect.objectContaining({
contactId: mockContact.id,
userId: mockUserId,
})
);
expect(responseNoteCache.revalidate).toHaveBeenCalled();
expect(response.contact).toEqual({ id: mockContact.id, userId: mockUserId });
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(prisma.response.create).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "2.0",
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
describe("Cloud specific tests", () => {
test("should check response limit and send event if limit reached", async () => {
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
});
test("should check response limit and not send event if limit not reached", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
const posthogError = new Error("Posthog error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
// Expecting successful response creation despite PostHog error
const response = await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
expect(response).toEqual(mockResponse); // Should still return the created response
});
});
});
describe("getResponsesByEnvironmentIds", () => {
test("should return responses successfully", async () => {
vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma);
vi.mocked(getResponseContact).mockReturnValue(null); // Assume no contact for simplicity
const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds);
expect(validateInputs).toHaveBeenCalledTimes(1);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
survey: {
environmentId: { in: mockEnvironmentIds },
},
},
orderBy: [{ createdAt: "desc" }],
take: undefined,
skip: undefined,
})
);
expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length);
expect(responses).toEqual(mockTransformedResponses);
expect(cache).toHaveBeenCalled();
});
test("should return responses with limit and offset", async () => {
vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma);
vi.mocked(getResponseContact).mockReturnValue(null);
await getResponsesByEnvironmentIds(mockEnvironmentIds, mockLimit, mockOffset);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: mockLimit,
skip: mockOffset,
})
);
expect(cache).toHaveBeenCalled();
});
test("should return empty array if no responses found", async () => {
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds);
expect(responses).toEqual([]);
expect(prisma.response.findMany).toHaveBeenCalled();
expect(getResponseContact).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "2.0",
});
vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError);
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
expect(cache).toHaveBeenCalled();
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.response.findMany).mockRejectedValue(genericError);
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError);
expect(cache).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,58 @@
import { responses } from "@/app/lib/api/response";
import { getUploadSignedUrl } from "@/lib/storage/service";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getSignedUrlForPublicFile } from "./getSignedUrl";
vi.mock("@/app/lib/api/response", () => ({
responses: {
successResponse: vi.fn((data) => ({ data })),
internalServerErrorResponse: vi.fn((message) => ({ message })),
},
}));
vi.mock("@/lib/storage/service", () => ({
getUploadSignedUrl: vi.fn(),
}));
describe("getSignedUrlForPublicFile", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should return success response with signed URL data", async () => {
const mockFileName = "test.jpg";
const mockEnvironmentId = "env123";
const mockFileType = "image/jpeg";
const mockSignedUrlResponse = {
signedUrl: "http://example.com/signed-url",
signingData: { signature: "sig", timestamp: 123, uuid: "uuid" },
updatedFileName: "test--fid--uuid.jpg",
fileUrl: "http://example.com/file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
expect(responses.successResponse).toHaveBeenCalledWith(mockSignedUrlResponse);
expect(result).toEqual({ data: mockSignedUrlResponse });
});
test("should return internal server error response when getUploadSignedUrl throws an error", async () => {
const mockFileName = "test.png";
const mockEnvironmentId = "env456";
const mockFileType = "image/png";
const mockError = new Error("Failed to get signed URL");
vi.mocked(getUploadSignedUrl).mockRejectedValue(mockError);
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
expect(responses.internalServerErrorResponse).toHaveBeenCalledWith("Internal server error");
expect(result).toEqual({ message: "Internal server error" });
});
});

View File

@@ -0,0 +1,153 @@
import { segmentCache } from "@/lib/cache/segment";
import { responseCache } from "@/lib/response/cache";
import { surveyCache } from "@/lib/survey/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteSurvey } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache/segment", () => ({
segmentCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
resultShareKey: "shareKey123",
};
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
type: "link",
segment: null,
triggers: [],
resultShareKey: null,
};
describe("deleteSurvey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(segmentCache.revalidate).not.toHaveBeenCalled(); // No segment to revalidate
expect(responseCache.revalidate).toHaveBeenCalledWith({ surveyId, environmentId });
expect(surveyCache.revalidate).toHaveBeenCalledTimes(1); // Only for surveyId
expect(surveyCache.revalidate).toHaveBeenCalledWith({
id: surveyId,
environmentId,
resultShareKey: undefined,
});
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(segmentCache.revalidate).not.toHaveBeenCalled();
expect(responseCache.revalidate).not.toHaveBeenCalled();
expect(surveyCache.revalidate).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
// Caches might have been partially revalidated before the error
});
test("should handle generic errors during deletion", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log generic errors here
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,11 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
@@ -96,19 +95,8 @@ export const PUT = async (
);
}
if (surveyUpdate.followUps && surveyUpdate.followUps.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) {
return responses.forbiddenResponse("Survey follow ups are not enabled for this organization");
}
}
if (surveyUpdate.languages && surveyUpdate.languages.length) {
const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan);
if (!isMultiLanguageEnabled) {
return responses.forbiddenResponse("Multi language is not enabled for this organization");
}
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) return featureCheckResult;
return responses.successResponse(await updateSurvey({ ...inputValidation.data, id: params.surveyId }));
} catch (error) {

View File

@@ -0,0 +1,187 @@
import { cache } from "@/lib/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/survey/cache");
vi.mock("@/lib/survey/utils");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger");
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock reactCache to just execute the function
};
});
const environmentId1 = "env1";
const environmentId2 = "env2";
const surveyId1 = "survey1";
const surveyId2 = "survey2";
const surveyId3 = "survey3";
const mockSurveyPrisma1 = {
id: surveyId1,
environmentId: environmentId1,
name: "Survey 1",
updatedAt: new Date(),
};
const mockSurveyPrisma2 = {
id: surveyId2,
environmentId: environmentId1,
name: "Survey 2",
updatedAt: new Date(),
};
const mockSurveyPrisma3 = {
id: surveyId3,
environmentId: environmentId2,
name: "Survey 3",
updatedAt: new Date(),
};
const mockSurveyTransformed1: TSurvey = {
...mockSurveyPrisma1,
displayPercentage: null,
segment: null,
} as TSurvey;
const mockSurveyTransformed2: TSurvey = {
...mockSurveyPrisma2,
displayPercentage: null,
segment: null,
} as TSurvey;
const mockSurveyTransformed3: TSurvey = {
...mockSurveyPrisma3,
displayPercentage: null,
segment: null,
} as TSurvey;
describe("getSurveys (Management API)", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mock the cache function to simply execute the underlying function
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => ({
...survey,
displayPercentage: null,
segment: null,
}));
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return surveys for a single environment ID with limit and offset", async () => {
const limit = 1;
const offset = 1;
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockSurveyPrisma2]);
const surveys = await getSurveys([environmentId1], limit, offset);
expect(validateInputs).toHaveBeenCalledWith(
[[environmentId1], expect.any(Object)],
[limit, expect.any(Object)],
[offset, expect.any(Object)]
);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId: { in: [environmentId1] } },
select: selectSurvey,
orderBy: { updatedAt: "desc" },
take: limit,
skip: offset,
});
expect(transformPrismaSurvey).toHaveBeenCalledTimes(1);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyPrisma2);
expect(surveys).toEqual([mockSurveyTransformed2]);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should return surveys for multiple environment IDs without limit and offset", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([
mockSurveyPrisma1,
mockSurveyPrisma2,
mockSurveyPrisma3,
]);
const surveys = await getSurveys([environmentId1, environmentId2]);
expect(validateInputs).toHaveBeenCalledWith(
[[environmentId1, environmentId2], expect.any(Object)],
[undefined, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId: { in: [environmentId1, environmentId2] } },
select: selectSurvey,
orderBy: { updatedAt: "desc" },
take: undefined,
skip: undefined,
});
expect(transformPrismaSurvey).toHaveBeenCalledTimes(3);
expect(surveys).toEqual([mockSurveyTransformed1, mockSurveyTransformed2, mockSurveyTransformed3]);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should return an empty array if no surveys are found", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
const surveys = await getSurveys([environmentId1]);
expect(prisma.survey.findMany).toHaveBeenCalled();
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(surveys).toEqual([]);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should handle PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2021",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys([environmentId1])).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys");
expect(cache).toHaveBeenCalledTimes(1);
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
await expect(getSurveys([environmentId1])).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw validation error for invalid input", async () => {
const invalidEnvId = "invalid-env";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getSurveys([invalidEnvId])).rejects.toThrow(validationError);
expect(prisma.survey.findMany).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalledTimes(1); // Cache wrapper is still called
});
});

View File

@@ -0,0 +1,231 @@
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import {
TSurveyCreateInputWithEnvironmentId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { checkFeaturePermissions } from "./utils";
// Mock dependencies
vi.mock("@/app/lib/api/response", () => ({
responses: {
forbiddenResponse: vi.fn((message) => new Response(message, { status: 403 })),
},
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
getMultiLanguagePermission: vi.fn(),
}));
vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
getSurveyFollowUpsPermission: vi.fn(),
}));
const mockOrganization: TOrganization = {
id: "test-org",
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
stripeCustomerId: null,
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
isAIEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
id: "followup1",
surveyId: "mockSurveyId",
name: "Test Follow-up",
trigger: {
type: "response",
properties: null,
},
action: {
type: "send-email",
properties: {
to: "mockQuestion1Id",
from: "noreply@example.com",
replyTo: [],
subject: "Follow-up Subject",
body: "Follow-up Body",
attachResponseData: false,
},
},
};
const mockLanguage: TSurveyCreateInputWithEnvironmentId["languages"][number] = {
language: {
id: "lang1",
code: "en",
alias: "English",
createdAt: new Date(),
projectId: "mockProjectId",
updatedAt: new Date(),
},
default: true,
enabled: true,
};
const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
name: "Test Survey",
environmentId: "test-env",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
charLimit: {},
inputType: "text",
},
],
endings: [],
languages: [],
type: "link",
welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false },
followUps: [],
};
describe("checkFeaturePermissions", () => {
test("should return null if no restricted features are used", async () => {
const surveyData = { ...baseSurveyData };
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
// Recaptcha tests
test("should return forbiddenResponse if recaptcha is enabled but permission denied", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false);
const surveyData = { ...baseSurveyData, recaptcha: { enabled: true, threshold: 0.5 } };
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"Spam protection is not enabled for this organization"
);
});
test("should return null if recaptcha is enabled and permission granted", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
const surveyData: TSurveyCreateInputWithEnvironmentId = {
...baseSurveyData,
recaptcha: { enabled: true, threshold: 0.5 },
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
// Follow-ups tests
test("should return forbiddenResponse if follow-ups are used but permission denied", async () => {
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
followUps: [mockFollowUp],
}; // Add minimal follow-up data
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"Survey follow ups are not allowed for this organization"
);
});
test("should return null if follow-ups are used and permission granted", async () => {
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true);
const surveyData = { ...baseSurveyData, followUps: [mockFollowUp] }; // Add minimal follow-up data
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
// Multi-language tests
test("should return forbiddenResponse if multi-language is used but permission denied", async () => {
vi.mocked(getMultiLanguagePermission).mockResolvedValue(false);
const surveyData: TSurveyCreateInputWithEnvironmentId = {
...baseSurveyData,
languages: [mockLanguage],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"Multi language is not enabled for this organization"
);
});
test("should return null if multi-language is used and permission granted", async () => {
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
languages: [mockLanguage],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
// Combined tests
test("should return null if multiple features are used and all permissions granted", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true);
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
recaptcha: { enabled: true, threshold: 0.5 },
followUps: [mockFollowUp],
languages: [mockLanguage],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
test("should return forbiddenResponse for the first denied feature (recaptcha)", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); // Denied
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true);
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
recaptcha: { enabled: true, threshold: 0.5 },
followUps: [mockFollowUp],
languages: [mockLanguage],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"Spam protection is not enabled for this organization"
);
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
});
test("should return forbiddenResponse for the first denied feature (follow-ups)", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false); // Denied
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
recaptcha: { enabled: true, threshold: 0.5 },
followUps: [mockFollowUp],
languages: [mockLanguage],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"Survey follow ups are not allowed for this organization"
);
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
});
});

View File

@@ -0,0 +1,33 @@
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
export const checkFeaturePermissions = async (
surveyData: TSurveyCreateInputWithEnvironmentId,
organization: TOrganization
): Promise<Response | null> => {
if (surveyData.recaptcha?.enabled) {
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.billing.plan);
if (!isSpamProtectionEnabled) {
return responses.forbiddenResponse("Spam protection is not enabled for this organization");
}
}
if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) {
return responses.forbiddenResponse("Survey follow ups are not allowed for this organization");
}
}
if (surveyData.languages?.length) {
const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan);
if (!isMultiLanguageEnabled) {
return responses.forbiddenResponse("Multi language is not enabled for this organization");
}
}
return null;
};

View File

@@ -1,11 +1,10 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
@@ -56,7 +55,7 @@ export const POST = async (request: Request): Promise<Response> => {
);
}
const environmentId = inputValidation.data.environmentId;
const { environmentId } = inputValidation.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
@@ -69,19 +68,8 @@ export const POST = async (request: Request): Promise<Response> => {
const surveyData = { ...inputValidation.data, environmentId };
if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) {
return responses.forbiddenResponse("Survey follow ups are not enabled allowed for this organization");
}
}
if (surveyData.languages && surveyData.languages.length) {
const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan);
if (!isMultiLanguageEnabled) {
return responses.forbiddenResponse("Multi language is not enabled for this organization");
}
}
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) return featureCheckResult;
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
return responses.successResponse(survey);

View File

@@ -0,0 +1,108 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Webhook } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ValidationError } from "@formbricks/types/errors";
import { deleteWebhook } from "./webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byId: () => "mockTag",
},
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
ValidationError: class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
},
}));
describe("deleteWebhook", () => {
afterEach(() => {
cleanup();
});
test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
const deletedWebhook = await deleteWebhook("test-webhook-id");
expect(deletedWebhook).toEqual(mockedWebhook);
expect(prisma.webhook.delete).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
expect(webhookCache.revalidate).toHaveBeenCalled();
});
test("should delete the webhook and call webhookCache.revalidate with correct parameters", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
const deletedWebhook = await deleteWebhook("test-webhook-id");
expect(deletedWebhook).toEqual(mockedWebhook);
expect(prisma.webhook.delete).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
expect(webhookCache.revalidate).toHaveBeenCalledWith({
id: mockedWebhook.id,
environmentId: mockedWebhook.environmentId,
source: mockedWebhook.source,
});
});
test("should throw an error when called with an invalid webhook ID format", async () => {
const { validateInputs } = await import("@/lib/utils/validate");
(validateInputs as any).mockImplementation(() => {
throw new ValidationError("Validation failed");
});
await expect(deleteWebhook("invalid-id")).rejects.toThrow(ValidationError);
expect(prisma.webhook.delete).not.toHaveBeenCalled();
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,203 @@
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("createWebhook", () => {
afterEach(() => {
cleanup();
});
test("should create a webhook and revalidate the cache when provided with valid input data", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
const createdWebhook = {
id: "webhook-id",
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user" as WebhookSource,
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
createdAt: new Date(),
updatedAt: new Date(),
} as any;
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
const result = await createWebhook(webhookInput);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.webhook.create).toHaveBeenCalledWith({
data: {
url: webhookInput.url,
name: webhookInput.name,
source: webhookInput.source,
surveyIds: webhookInput.surveyIds,
triggers: webhookInput.triggers,
environment: {
connect: {
id: webhookInput.environmentId,
},
},
},
});
expect(webhookCache.revalidate).toHaveBeenCalledWith({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
expect(result).toEqual(createdWebhook);
});
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
const invalidWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: 123, // Invalid URL
source: "user" as WebhookSource,
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
vi.mocked(validateInputs).mockImplementation(() => {
throw new ValidationError("Validation failed");
});
await expect(createWebhook(invalidWebhookInput as any)).rejects.toThrowError(ValidationError);
});
test("should throw a DatabaseError if a PrismaClientKnownRequestError occurs", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "5.0.0",
})
);
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
});
test("should call webhookCache.revalidate with the correct parameters after successfully creating a webhook", async () => {
const webhookInput: TWebhookInput = {
environmentId: "env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
const createdWebhook = {
id: "webhook123",
environmentId: "env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
createdAt: new Date(),
updatedAt: new Date(),
} as any;
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
await createWebhook(webhookInput);
expect(webhookCache.revalidate).toHaveBeenCalledWith({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
});
test("should throw a DatabaseError when provided with invalid surveyIds", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["invalid-survey-id"],
};
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Foreign key constraint violation"));
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
});
test("should handle edge case URLs that are technically valid but problematic", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "http://localhost:3000", // Example of a potentially problematic URL
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new DatabaseError("Invalid URL"));
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.webhook.create).toHaveBeenCalledWith({
data: {
url: webhookInput.url,
name: webhookInput.name,
source: webhookInput.source,
surveyIds: webhookInput.surveyIds,
triggers: webhookInput.triggers,
environment: {
connect: {
id: webhookInput.environmentId,
},
},
},
});
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { Organization } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { getOrganizationBillingByEnvironmentId } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("@/lib/organization/cache", () => ({
organizationCache: {
tag: {
byEnvironmentId: (id: string) => `tag-${id}`,
},
},
}));
vi.mock("react", () => ({
cache: (fn: any) => fn,
}));
describe("getOrganizationBillingByEnvironmentId", () => {
const environmentId = "env-123";
const mockBillingData: Organization["billing"] = {
limits: {
monthly: { miu: 0, responses: 0 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
plan: "scale",
stripeCustomerId: "mock-stripe-customer-id",
};
test("returns billing when organization is found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: mockBillingData });
const result = await getOrganizationBillingByEnvironmentId(environmentId);
expect(result).toEqual(mockBillingData);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: {
projects: {
some: {
environments: {
some: {
id: environmentId,
},
},
},
},
},
select: {
billing: true,
},
});
});
test("returns null when organization is not found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce(null);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
expect(result).toBeNull();
});
test("logs error and returns null on exception", async () => {
const error = new Error("db error");
vi.mocked(prisma.organization.findFirst).mockRejectedValueOnce(error);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(error, "Failed to get organization billing by environment ID");
});
});

View File

@@ -0,0 +1,45 @@
import { cache } from "@/lib/cache";
import { organizationCache } from "@/lib/organization/cache";
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
export const getOrganizationBillingByEnvironmentId = reactCache(
async (environmentId: string): Promise<Organization["billing"] | null> =>
cache(
async () => {
try {
const organization = await prisma.organization.findFirst({
where: {
projects: {
some: {
environments: {
some: {
id: environmentId,
},
},
},
},
},
select: {
billing: true,
},
});
if (!organization) {
return null;
}
return organization.billing;
} catch (error) {
logger.error(error, "Failed to get organization billing by environment ID");
return null;
}
},
[`api-v2-client-getOrganizationBillingByEnvironmentId-${environmentId}`],
{
tags: [organizationCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { verifyRecaptchaToken } from "./recaptcha";
// Mock constants
vi.mock("@/lib/constants", () => ({
RECAPTCHA_SITE_KEY: "test-site-key",
RECAPTCHA_SECRET_KEY: "test-secret-key",
}));
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
warn: vi.fn(),
error: vi.fn(),
},
}));
describe("verifyRecaptchaToken", () => {
beforeEach(() => {
vi.resetAllMocks();
global.fetch = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
});
test("returns true if site key or secret key is missing", async () => {
vi.doMock("@/lib/constants", () => ({
RECAPTCHA_SITE_KEY: undefined,
RECAPTCHA_SECRET_KEY: undefined,
}));
// Re-import to get new mocked values
const { verifyRecaptchaToken: verifyWithNoKeys } = await import("./recaptcha");
const result = await verifyWithNoKeys("token", 0.5);
expect(result).toBe(true);
expect(logger.warn).toHaveBeenCalledWith("reCAPTCHA verification skipped: keys not configured");
});
test("returns false if fetch response is not ok", async () => {
(global.fetch as any).mockResolvedValue({ ok: false });
const result = await verifyRecaptchaToken("token", 0.5);
expect(result).toBe(false);
});
test("returns false if verification fails (data.success is false)", async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ success: false }),
});
const result = await verifyRecaptchaToken("token", 0.5);
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith({ success: false }, "reCAPTCHA verification failed");
});
test("returns false if score is below or equal to threshold", async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ success: true, score: 0.3 }),
});
const result = await verifyRecaptchaToken("token", 0.5);
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
{ success: true, score: 0.3 },
"reCAPTCHA score below threshold"
);
});
test("returns true if verification is successful and score is above threshold", async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ success: true, score: 0.9 }),
});
const result = await verifyRecaptchaToken("token", 0.5);
expect(result).toBe(true);
});
test("returns true if verification is successful and score is undefined", async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ success: true }),
});
const result = await verifyRecaptchaToken("token", 0.5);
expect(result).toBe(true);
});
test("returns false and logs error if fetch throws", async () => {
(global.fetch as any).mockRejectedValue(new Error("network error"));
const result = await verifyRecaptchaToken("token", 0.5);
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Error verifying reCAPTCHA token");
});
test("aborts fetch after timeout", async () => {
vi.useFakeTimers();
let abortCalled = false;
const abortController = {
abort: () => {
abortCalled = true;
},
signal: {},
};
vi.spyOn(global, "AbortController").mockImplementation(() => abortController as any);
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
verifyRecaptchaToken("token", 0.5);
vi.advanceTimersByTime(5000);
expect(abortCalled).toBe(true);
});
});

View File

@@ -0,0 +1,62 @@
import { RECAPTCHA_SECRET_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { logger } from "@formbricks/logger";
/**
* Verifies a reCAPTCHA token with Google's reCAPTCHA API
* @param token The reCAPTCHA token to verify
* @param threshold The minimum score threshold (0.0 to 1.0)
* @returns A promise that resolves to true if the verification is successful and the score meets the threshold, false otherwise
*/
export const verifyRecaptchaToken = async (token: string, threshold: number): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
// If keys aren't configured, skip verification
if (!RECAPTCHA_SITE_KEY || !RECAPTCHA_SECRET_KEY) {
logger.warn("reCAPTCHA verification skipped: keys not configured");
return true;
}
// Build URL-encoded form data
const params = new URLSearchParams();
params.append("secret", RECAPTCHA_SECRET_KEY);
params.append("response", token);
// POST to Googles siteverify endpoint
const response = await fetch("https://www.google.com/recaptcha/api/siteverify", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
signal: controller.signal,
});
if (!response.ok) {
logger.error(`reCAPTCHA HTTP error: ${response.status}`);
return false;
}
const data = await response.json();
// Check if verification was successful
if (!data.success) {
logger.error(data, "reCAPTCHA verification failed");
return false;
}
// Check if the score meets the threshold
if (data.score !== undefined && data.score < threshold) {
logger.error(data, "reCAPTCHA score below threshold");
return false;
}
return true;
} catch (error) {
logger.error(error, "Error verifying reCAPTCHA token");
return false;
} finally {
clearTimeout(timeoutId);
}
};

View File

@@ -0,0 +1,209 @@
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn().mockImplementation((value, language) => {
return typeof value === "string" ? value : value[language] || value["default"] || "";
}),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({
verifyRecaptchaToken: vi.fn(),
}));
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 })),
},
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({
getOrganizationBillingByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const mockSurvey: TSurvey = {
id: "survey-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
environmentId: "env-1",
type: "link",
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
singleUse: null,
triggers: [],
languages: [],
pin: null,
resultShareKey: null,
segment: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false },
variables: [],
createdBy: null,
recaptcha: { enabled: false, threshold: 0.5 },
displayLimit: null,
endings: [],
followUps: [],
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
};
const mockResponseInput: TResponseInputV2 = {
surveyId: "survey-1",
environmentId: "env-1",
data: {},
finished: false,
ttc: {},
meta: {},
};
const mockBillingData: Organization["billing"] = {
limits: {
monthly: { miu: 0, responses: 0 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
plan: "scale",
stripeCustomerId: "mock-stripe-customer-id",
};
describe("checkSurveyValidity", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("should return badRequestResponse if survey environmentId does not match", async () => {
const survey = { ...mockSurvey, environmentId: "env-2" };
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another environment",
{
"survey.environmentId": "env-2",
environmentId: "env-1",
},
true
);
});
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);
expect(result).toBeNull();
});
test("should return badRequestResponse if recaptcha enabled, spam protection enabled, but token is missing", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
const responseInputWithoutToken = { ...mockResponseInput };
delete responseInputWithoutToken.recaptchaToken;
const result = await checkSurveyValidity(survey, "env-1", responseInputWithoutToken);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(logger.error).toHaveBeenCalledWith("Missing recaptcha token");
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing recaptcha token",
{ code: "recaptcha_verification_failed" },
true
);
});
test("should return not found response if billing data is not found", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(null);
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
recaptchaToken: "test-token",
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(404);
expect(responses.notFoundResponse).toHaveBeenCalledWith("Organization", null);
expect(getOrganizationBillingByEnvironmentId).toHaveBeenCalledWith("env-1");
});
test("should return null if recaptcha is enabled but spam protection is disabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
recaptchaToken: "test-token",
});
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith("Spam protection is not enabled for this organization");
});
test("should return badRequestResponse if recaptcha verification fails", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(false);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(verifyRecaptchaToken).toHaveBeenCalledWith("test-token", 0.5);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"reCAPTCHA verification failed",
{ code: "recaptcha_verification_failed" },
true
);
});
test("should return null if recaptcha verification passes", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken);
expect(result).toBeNull();
expect(verifyRecaptchaToken).toHaveBeenCalledWith("test-token", 0.5);
});
test("should return null for a valid survey and input", async () => {
const survey = { ...mockSurvey }; // Recaptcha disabled by default in mock
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,63 @@
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 { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed";
export const checkSurveyValidity = async (
survey: TSurvey,
environmentId: string,
responseInput: TResponseInputV2
): Promise<Response | null> => {
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
if (survey.recaptcha?.enabled) {
if (!responseInput.recaptchaToken) {
logger.error("Missing recaptcha token");
return responses.badRequestResponse(
"Missing recaptcha token",
{
code: RECAPTCHA_VERIFICATION_ERROR_CODE,
},
true
);
}
const billing = await getOrganizationBillingByEnvironmentId(environmentId);
if (!billing) {
return responses.notFoundResponse("Organization", null);
}
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(billing.plan);
if (!isSpamProtectionEnabled) {
logger.error("Spam protection is not enabled for this organization");
}
const isPassed = await verifyRecaptchaToken(responseInput.recaptchaToken, survey.recaptcha.threshold);
if (!isPassed) {
return responses.badRequestResponse(
"reCAPTCHA verification failed",
{
code: RECAPTCHA_VERIFICATION_ERROR_CODE,
},
true
);
}
}
return null;
};

View File

@@ -1,8 +1,10 @@
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
@@ -74,14 +76,23 @@ export const POST = async (request: Request, context: Context): Promise<Response
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
if (survey.environmentId !== environmentId) {
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
if (surveyCheckResult) return surveyCheckResult;
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: survey.questions,
responseLanguage: responseInputData.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
"Survey is part of another environment",
`Response exceeds character limit`,
{
"survey.environmentId": survey.environmentId,
environmentId,
questionId: otherResponseInvalidQuestionId,
},
true
);
@@ -108,15 +119,14 @@ export const POST = async (request: Request, context: Context): Promise<Response
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
}
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
}
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
environmentId,
surveyId: response.surveyId,
response: response,
});
@@ -124,13 +134,13 @@ export const POST = async (request: Request, context: Context): Promise<Response
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
environmentId,
surveyId: response.surveyId,
response: response,
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
await capturePosthogEnvironmentEvent(environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});

View File

@@ -2,5 +2,8 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseInput } from "@formbricks/types/responses";
export const ZResponseInputV2 = ZResponseInput.omit({ userId: true }).extend({ contactId: ZId.nullish() });
export const ZResponseInputV2 = ZResponseInput.omit({ userId: true }).extend({
contactId: ZId.nullish(),
recaptchaToken: z.string().nullish(),
});
export type TResponseInputV2 = z.infer<typeof ZResponseInputV2>;

View File

@@ -1,11 +0,0 @@
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import formbricks from "@formbricks/js";
export const formbricksLogout = async () => {
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
localStorage.clear();
if (loggedInWith) {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, loggedInWith);
}
return await formbricks.logout();
};

View File

@@ -3567,6 +3567,7 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
displayLimit: null,
autoClose: null,
runOnDate: null,
recaptcha: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,

View File

@@ -3,9 +3,12 @@ import { TUserLocale } from "@formbricks/types/user";
import { env } from "./env";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const FORMBRICKS_API_HOST = env.FORMBRICKS_API_HOST;
export const FORMBRICKS_ENVIRONMENT_ID = env.FORMBRICKS_ENVIRONMENT_ID;
export const IS_FORMBRICKS_ENABLED = !!(env.FORMBRICKS_API_HOST && env.FORMBRICKS_ENVIRONMENT_ID);
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
export const WEBAPP_URL =
@@ -30,13 +33,11 @@ export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? true : false;
export const GITHUB_OAUTH_ENABLED = env.GITHUB_ID && env.GITHUB_SECRET ? true : false;
export const AZURE_OAUTH_ENABLED =
env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET && env.AZUREAD_TENANT_ID ? true : false;
export const OIDC_OAUTH_ENABLED =
env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER ? true : false;
export const SAML_OAUTH_ENABLED = env.SAML_DATABASE_URL ? true : false;
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
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);
export const SAML_OAUTH_ENABLED = !!env.SAML_DATABASE_URL;
export const SAML_XML_DIR = "./saml-connection";
export const GITHUB_ID = env.GITHUB_ID;
@@ -60,7 +61,7 @@ export const SAML_PRODUCT = "formbricks";
export const SAML_AUDIENCE = "https://saml.formbricks.com";
export const SAML_PATH = "/api/auth/saml/callback";
export const SIGNUP_ENABLED = env.SIGNUP_DISABLED !== "1";
export const SIGNUP_ENABLED = IS_FORMBRICKS_CLOUD || IS_DEVELOPMENT || E2E_TESTING;
export const EMAIL_AUTH_ENABLED = env.EMAIL_AUTH_DISABLED !== "1";
export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
@@ -96,9 +97,11 @@ export const RESPONSES_PER_PAGE = 25;
export const TEXT_RESPONSES_PER_PAGE = 5;
export const INSIGHTS_PER_PAGE = 10;
export const DOCUMENTS_PER_PAGE = 10;
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
export const MAX_OTHER_OPTION_LENGTH = 250;
export const DEFAULT_ORGANIZATION_ID = env.DEFAULT_ORGANIZATION_ID;
export const DEFAULT_ORGANIZATION_ROLE = env.DEFAULT_ORGANIZATION_ROLE;
export const SKIP_INVITE_FOR_SSO = env.AUTH_SKIP_INVITE_FOR_SSO === "1";
export const DEFAULT_TEAM_ID = env.AUTH_DEFAULT_TEAM_ID;
export const SLACK_MESSAGE_LIMIT = 2995;
export const GOOGLE_SHEET_MESSAGE_LIMIT = 49995;
@@ -112,7 +115,7 @@ export const S3_REGION = env.S3_REGION;
export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL;
export const S3_BUCKET_NAME = env.S3_BUCKET_NAME;
export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1";
export const UPLOADS_DIR = env.UPLOADS_DIR || "./uploads";
export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads";
export const MAX_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
@@ -196,7 +199,6 @@ export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
};
export const DEBUG = env.DEBUG === "1";
export const E2E_TESTING = env.E2E_TESTING === "1";
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
@@ -216,7 +218,7 @@ export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
export const STRIPE_API_VERSION = "2024-06-20";
// Maximum number of attribute classes allowed:
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150 as const;
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"];
@@ -273,9 +275,9 @@ export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
export const SENTRY_DSN = env.SENTRY_DSN;

View File

@@ -17,19 +17,13 @@ export const env = createEnv({
DATABASE_URL: z.string().url(),
DEBUG: z.enum(["1", "0"]).optional(),
DOCKER_CRON_ENABLED: z.enum(["1", "0"]).optional(),
DEFAULT_ORGANIZATION_ID: z.string().optional(),
DEFAULT_ORGANIZATION_ROLE: z.enum(["owner", "manager", "member", "billing"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
FORMBRICKS_API_HOST: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -81,7 +75,6 @@ export const env = createEnv({
S3_FORCE_PATH_STYLE: z.enum(["1", "0"]).optional(),
SAML_DATABASE_URL: z.string().optional(),
SENTRY_DSN: z.string().optional(),
SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SMTP_HOST: z.string().min(1).optional(),
@@ -102,6 +95,8 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
TURNSTILE_SECRET_KEY: z.string().optional(),
TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
UPLOADS_DIR: z.string().min(1).optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.string().url().optional(),
@@ -129,16 +124,14 @@ export const env = createEnv({
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DEBUG: process.env.DEBUG,
DEFAULT_ORGANIZATION_ID: process.env.DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE: process.env.DEFAULT_ORGANIZATION_ROLE,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
DOCKER_CRON_ENABLED: process.env.DOCKER_CRON_ENABLED,
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
FORMBRICKS_API_HOST: process.env.FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID: process.env.FORMBRICKS_ENVIRONMENT_ID,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
@@ -182,7 +175,6 @@ export const env = createEnv({
S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL,
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
SAML_DATABASE_URL: process.env.SAML_DATABASE_URL,
SIGNUP_DISABLED: process.env.SIGNUP_DISABLED,
SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID,
SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET,
SMTP_HOST: process.env.SMTP_HOST,
@@ -198,6 +190,8 @@ export const env = createEnv({
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
UPLOADS_DIR: process.env.UPLOADS_DIR,
VERCEL_URL: process.env.VERCEL_URL,

View File

@@ -309,6 +309,7 @@ export const mockSurvey: TSurvey = {
isVerifyEmailEnabled: false,
projectOverwrites: null,
styling: null,
recaptcha: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,

View File

@@ -63,18 +63,35 @@ export const createMembership = async (
},
});
if (existingMembership) {
if (existingMembership && existingMembership.role === data.role) {
return existingMembership;
}
const membership = await prisma.membership.create({
data: {
userId,
organizationId,
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
let membership: TMembership;
if (!existingMembership) {
membership = await prisma.membership.create({
data: {
userId,
organizationId,
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
} else {
membership = await prisma.membership.update({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
data: {
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
}
organizationCache.revalidate({
userId,
});

View File

@@ -512,6 +512,7 @@ export const mockSurvey: TSurvey = {
autoComplete: null,
isVerifyEmailEnabled: false,
projectOverwrites: null,
recaptcha: null,
styling: null,
surveyClosedMessage: null,
singleUse: {

View File

@@ -69,7 +69,7 @@ export const processResponseData = (
if (Array.isArray(responseData)) {
responseData = responseData
.filter((item) => item !== null && item !== undefined && item !== "")
.join(", ");
.join("; ");
return responseData;
} else {
const formattedString = Object.entries(responseData)

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