Compare commits

..

52 Commits

Author SHA1 Message Date
Dhruwang 5d68d1f524 refactor: adjust padding and text handling in OpenID button
Updated the OpenID button to include horizontal padding and simplified the text rendering logic. This change ensures better alignment and visual consistency, while maintaining the truncation behavior for long text. The 'last used' indicator remains unaffected.
2026-04-15 14:55:05 +05:30
xhamzax ed0c36a87f refactor: use flex layout instead of absolute positioning for last-used badge
Addresses coderabbitai feedback: replace the absolute-positioned badge
with a flex sibling layout (gap-2) so the label can truncate naturally
and the badge gets a stable gap regardless of text length. Removes the
magic pr-16 padding and absolute right-3 that broke on long translations.
2026-04-14 05:36:42 +01:00
xhamzax c0ed93be7a fix: prevent OIDC button text overlap with 'last used' indicator
When OIDC_DISPLAY_NAME is set to a long value (e.g. 'OIDC lemonldap'),
the centered button text can extend into the area where the absolutely-
positioned 'last used' indicator sits (right-3, opacity-50). In Firefox,
this causes visible text bleed-through where the two labels overlap.

Wrap the main text in a truncate span so it ellipsis-truncates before
reaching the indicator area, and add overflow-hidden on the button to
clip any remaining overflow. When lastUsed is shown, apply pr-16 to
leave room for the indicator. Add shrink-0 on the indicator span so
it doesn't collapse.

Fixes #7539
2026-04-14 05:36:42 +01:00
Dhruwang Jariwala 439dd0b44e fix: add loading skeleton for responses page (#7700)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-13 16:56:20 +00:00
Anshuman Pandey 2556f5e15d fix: add missing PostHog events (#7722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:57:12 +00:00
Johannes cc0eec3bf0 feat: add auto-progress mode for rating and NPS surveys (#7709)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-13 11:22:50 +00:00
Johannes 4b009a8eb4 revert: enhance welcome card to support video uploads (#7712)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 08:17:05 +00:00
Johannes 2aaddf7306 fix: prevent TTC overcount for multi-question blocks (#7713)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 07:56:40 +00:00
Dhruwang Jariwala fb5d6145d0 fix: only show beforeunload warning when offline support is active (#7715) 2026-04-13 07:19:57 +00:00
Dhruwang Jariwala 59310bac93 fix: validate "Other" option text on required questions and remove duplicate response entry (#7716) 2026-04-13 07:05:08 +00:00
Dhruwang Jariwala 322f0be197 fix: improve restricted ID validation toast with i18n support (#7703)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-12 06:18:13 +00:00
Manuel Delgado 1a02f91afd fix(api): return 409 Conflict instead of 500 when creating user with duplicate email (#7675)
Co-authored-by: Tiago Farto <tiago@formbricks.com>
2026-04-10 14:28:17 +00:00
Tiago cc22ccb22d chore: Harden SSO account linking for existing email-based accounts (#7702) 2026-04-10 14:19:21 +00:00
Tiago 12763f0ef6 fix: Dutch translations for link survey footer (Privacy Policy, Imprint, Report Survey) (#7707) 2026-04-10 13:42:15 +00:00
Dhruwang Jariwala d39e3ee638 feat: offline support for link surveys (#7694)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-10 11:27:48 +00:00
dingdyan d85242a86b fix: handle internal server error toast behavior in create organization (#7662)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-10 11:13:10 +00:00
Bhagya Amarasinghe ef53065abc feat: support GKE Envoy ingress split with numeric ports and service annotations (#7704) 2026-04-10 09:22:19 +00:00
Dhruwang Jariwala 805c1c6874 fix: (duplicate) server error toast handling (#7701) 2026-04-10 09:22:16 +00:00
Niels Kaspers 01687e8907 fix: add TERMS_URL support to survey link footers (#7670) 2026-04-10 09:21:11 +00:00
Johannes 31d455002d feat: unifiy nav auth behaviour (#7635)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-09 14:26:14 +00:00
Johannes d96304d86d fix: make navigation more user-friendly (#7599)
Co-authored-by: Tiago Farto <tiago@formbricks.com>
2026-04-09 08:03:24 +00:00
Bhagya Amarasinghe 1064f68435 fix: support OTEL host config for envoy telemetry (#7692) 2026-04-09 07:25:52 +00:00
Anshuman Pandey 3d16e859c6 feat: custom posthog events (#7647) 2026-04-09 05:34:01 +00:00
Salim B af198c5632 docs: remove spurious left-overs (#7690) 2026-04-08 16:11:30 +00:00
Bhagya Amarasinghe a43ed2b25c feat: add envoy gateway helm bundle (#7686) 2026-04-08 07:34:47 +00:00
Tiago 87bcad2b20 feat: Supporting different AI providers within Formbricks (#7611)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-06 05:45:12 +00:00
Anshuman Pandey b5eaa4c7fd fix: merge epic/improve-telemetry into main (#7666) 2026-04-03 10:12:51 +00:00
Tiago 995c03bc01 chore: Revoke all active sessions after password reset (#7628) 2026-04-03 06:10:28 +00:00
Johannes b4395a48c5 fix: multi-lang toggle covering arabic text (#7657)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-02 13:09:16 +00:00
Johannes 461e3893fe fix: 7549 multilang button overflow (#7656)
Co-authored-by: Niels Kaspers <kaspersniels@gmail.com>
2026-04-02 12:53:57 +00:00
Tiago 735a9f84ec fix: harden api error reporting for v2/v1 Sentry observability (#7633) 2026-04-02 12:08:44 +00:00
Dhruwang Jariwala 8cb8d734cf fix: prevent language switch from breaking survey orientation and resetting language on auto-save (#7654)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 12:08:12 +00:00
Anshuman Pandey 44d5530b48 fix: adds formbricks instance on window (#7630) 2026-04-02 07:26:48 +00:00
Matti Nannt a314eb391e chore: add Codex environment config (#7589) 2026-04-02 07:24:02 +00:00
Matti Nannt 6c34c316d0 docs: remove non-official self-hosting options from README.md 2026-04-01 14:16:47 +02:00
Matti Nannt 4f26278f16 docs: add German README summary (#7641) 2026-04-01 11:04:15 +02:00
Tiago b975e7fa2e feat: Make password reset links single-use and revocable (#7627) 2026-04-01 07:12:37 +00:00
Johannes 6c3052f9e4 fix: correct CSAT template option order for question 2 (#7636)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-01 07:11:27 +00:00
Dhruwang Jariwala 5bb8119ebf feat: split AI toggle into smart tools and data analysis settings (#7563) 2026-03-31 11:23:51 +00:00
Johannes 02411277d4 revert: remove fake-door workflows experiment (#7392) (#7631)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-03-31 10:47:33 +00:00
Dhruwang Jariwala 4cfb8c6d7b fix: resolve language code case mismatch in link survey rendering (#7624)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:34:20 +00:00
Anshuman Pandey e74a51a5ff fix: sync segment state after auto-save to prevent stale reference on publish (#7619) 2026-03-30 06:51:44 +00:00
Dhruwang Jariwala 29cc6a10fe fix: prevent auto-save from overwriting survey status during publish (#7618) 2026-03-30 06:34:20 +00:00
Bhagya Amarasinghe 01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey 9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR 697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
Dhruwang Jariwala 83bc272ed2 fix: prevent duplicate hobby subscriptions from race condition (#7597)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:50:52 +00:00
Dhruwang Jariwala 59cc9c564e fix: duplicate org creation (#7593) 2026-03-26 05:52:09 +00:00
Dhruwang Jariwala 20dc147682 fix: scrolling behaviour to invalid questions (#7573) 2026-03-25 13:35:51 +00:00
cursor[bot] 2bb7a6f277 fix: prevent TypeError when checking for duplicate matrix labels (#7579)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-25 13:14:18 +00:00
Dhruwang Jariwala deb062dd03 fix: handle 404 race condition in Stripe webhook reconciliation (#7584) 2026-03-25 09:58:00 +00:00
Dhruwang Jariwala 474be86d33 fix: translations for option types (#7576) 2026-03-24 13:18:26 +00:00
390 changed files with 17720 additions and 3262 deletions
+9
View File
@@ -0,0 +1,9 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''
+38 -3
View File
@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1 PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email. # Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1 # EMAIL_AUTH_DISABLED=1
@@ -132,6 +138,31 @@ AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET= AZUREAD_CLIENT_SECRET=
AZUREAD_TENANT_ID= AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, gcp, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=gcp
# AI_MODEL=gemini-2.5-flash
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
# AI_AWS_ACCESS_KEY_ID=
# AI_AWS_SECRET_ACCESS_KEY=
# AI_AWS_SESSION_TOKEN=
# Azure AI / Microsoft Foundry credentials
# AI_AZURE_BASE_URL=
# AI_AZURE_RESOURCE_NAME=
# AI_AZURE_API_KEY=
# AI_AZURE_API_VERSION=v1
# OpenID Connect (OIDC) configuration # OpenID Connect (OIDC) configuration
# OIDC_CLIENT_ID= # OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET= # OIDC_CLIENT_SECRET=
@@ -185,9 +216,13 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # RATE_LIMITING_DISABLED=1
# Public unauthenticated IP-based rate limits can be handled by an edge provider. # Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
# Supported values: none, cloudflare, cloudarmor, envoy # TELEMETRY_DISABLED=1
# EDGE_RATE_LIMIT_PROVIDER=none
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics) # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv .direnv
# Playwright # Playwright
/test-results/ **/test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
+13 -1
View File
@@ -1 +1,13 @@
pnpm lint-staged #!/usr/bin/env sh
if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
elif command -v npm >/dev/null 2>&1; then
npm exec --yes pnpm@10.32.1 lint-staged
elif command -v corepack >/dev/null 2>&1; then
corepack pnpm lint-staged
else
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "Install Node.js tooling or update your PATH, then retry the commit."
exit 127
fi
+1 -25
View File
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription. Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker #### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment). To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community-managed One Click Hosting
##### Railway
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development ## 👨‍💻 Development
### Prerequisites ### Prerequisites
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come. The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<p align="right"><a href="#top">🔼 Back to top</a></p> <a id="readme-de"></a>
@@ -26,7 +26,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember } = getAccessFlags(membership?.role); const { isMember, isBilling } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
return ( return (
<div className="flex min-h-full min-w-full flex-row"> <div className="flex min-h-full min-w-full flex-row">
@@ -45,6 +46,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isOwnerOrManager={false} isOwnerOrManager={false}
isAccessControlAllowed={false} isAccessControlAllowed={false}
isMember={isMember} isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]} environments={[]}
/> />
</div> </div>
@@ -75,6 +75,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role} membershipRole={membership.role}
publicDomain={publicDomain} publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/> />
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50"> <div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar <TopControlBar
@@ -2,42 +2,59 @@
import { import {
ArrowUpRightIcon, ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon, ChevronRightIcon,
Cog, Cog,
FoldersIcon,
Loader2,
LogOutIcon, LogOutIcon,
MessageCircle, MessageCircle,
PanelLeftCloseIcon, PanelLeftCloseIcon,
PanelLeftOpenIcon, PanelLeftOpenIcon,
PlusIcon,
RocketIcon, RocketIcon,
SettingsIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
WorkflowIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import {
getOrganizationsForSwitcherAction,
getProjectsForSwitcherAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils"; import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert"; import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions"; import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import packageJson from "../../../../../package.json"; import packageJson from "../../../../../package.json";
interface NavigationProps { interface NavigationProps {
@@ -49,8 +66,31 @@ interface NavigationProps {
isDevelopment: boolean; isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
publicDomain: string; publicDomain: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
} }
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
if (pathname.includes("/settings/")) {
return false;
}
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
if (accountSettingsPattern.test(pathname)) {
return false;
}
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const MainNavigation = ({ export const MainNavigation = ({
environment, environment,
organization, organization,
@@ -60,6 +100,10 @@ export const MainNavigation = ({
isFormbricksCloud, isFormbricksCloud,
isDevelopment, isDevelopment,
publicDomain, publicDomain,
isMultiOrgEnabled,
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -69,7 +113,12 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState(""); const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole); const [isPending, startTransition] = useTransition();
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const disabledNavigationMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
@@ -106,6 +155,7 @@ export const MainNavigation = ({
icon: MessageCircle, icon: MessageCircle,
isActive: pathname?.includes("/surveys"), isActive: pathname?.includes("/surveys"),
isHidden: false, isHidden: false,
disabled: isMembershipPending || isBilling,
}, },
{ {
href: `/environments/${environment.id}/contacts`, href: `/environments/${environment.id}/contacts`,
@@ -115,22 +165,17 @@ export const MainNavigation = ({
pathname?.includes("/contacts") || pathname?.includes("/contacts") ||
pathname?.includes("/segments") || pathname?.includes("/segments") ||
pathname?.includes("/attributes"), pathname?.includes("/attributes"),
}, disabled: isMembershipPending || isBilling,
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
}, },
{ {
name: t("common.configuration"), name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`, href: `/environments/${environment.id}/workspace/general`,
icon: Cog, icon: Cog,
isActive: pathname?.includes("/workspace"), isActive: pathname?.includes("/workspace"),
disabled: isMembershipPending || isBilling,
}, },
], ],
[t, environment.id, pathname, isFormbricksCloud] [t, environment.id, pathname, isMembershipPending, isBilling]
); );
const dropdownNavigation = [ const dropdownNavigation = [
@@ -153,6 +198,183 @@ export const MainNavigation = ({
}, },
]; ];
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${environment.id}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${environment.id}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${environment.id}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${environment.id}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${environment.id}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${environment.id}/workspace/tags`,
},
];
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${environment.id}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environment.id}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environment.id}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environment.id}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environment.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
setWorkspaceLoadError(null);
try {
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setWorkspaceLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
);
} finally {
setIsLoadingProjects(false);
setHasInitializedProjects(true);
}
}, [organization.id, t]);
useEffect(() => {
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
return;
}
loadProjects();
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setOrganizationLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
);
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
!isOrganizationDropdownOpen ||
organizations.length > 0 ||
isLoadingOrganizations ||
organizationLoadError
) {
return;
}
loadOrganizations();
}, [
isOrganizationDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
useEffect(() => { useEffect(() => {
async function loadReleases() { async function loadReleases() {
const res = await getLatestStableFbReleaseAction(); const res = await getLatestStableFbReleaseAction();
@@ -182,7 +404,79 @@ export const MainNavigation = ({
organization.billing?.stripe?.trialEnd, organization.billing?.stripe?.trialEnd,
]); ]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`; const mainNavigationLink = isBilling
? getBillingFallbackPath(environment.id, isFormbricksCloud)
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
if (projectId === project.id) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleProjectCreate = () => {
if (!hasInitializedProjects || isLoadingProjects) {
return;
}
if (projects.length >= organizationProjectsLimit) {
setOpenProjectLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${environment.id}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${environment.id}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
};
const switcherTriggerClasses = cn(
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
return ( return (
<> <>
@@ -222,24 +516,24 @@ export const MainNavigation = ({
</div> </div>
{/* Main Nav Switch */} {/* Main Nav Switch */}
{!isBilling && ( <ul>
<ul> {mainNavigation.map(
{mainNavigation.map( (item) =>
(item) => !item.isHidden && (
!item.isHidden && ( <NavigationLink
<NavigationLink key={item.name}
key={item.name} href={item.href}
href={item.href} isActive={item.isActive}
isActive={item.isActive} isCollapsed={isCollapsed}
isCollapsed={isCollapsed} isTextVisible={isTextVisible}
isTextVisible={isTextVisible} disabled={item.disabled}
linkText={item.name}> disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
<item.icon strokeWidth={1.5} /> linkText={item.name}>
</NavigationLink> <item.icon strokeWidth={1.5} />
) </NavigationLink>
)} )
</ul> )}
)} </ul>
</div> </div>
<div> <div>
@@ -263,38 +557,210 @@ export const MainNavigation = ({
</Link> </Link>
)} )}
{/* User Switch */} <div className="flex flex-col">
<div className="flex items-center"> <DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingProjects || isInitialProjectsLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects &&
!isInitialProjectsLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setProjects([]);
},
t("common.try_again")
)}
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === project.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleProjectCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
if (setting.hidden) return null;
return (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none"> className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<div <button
className={cn( type="button"
"flex cursor-pointer flex-row items-center gap-3", aria-label={isCollapsed ? t("common.account_settings") : undefined}
isCollapsed ? "justify-center px-2" : "px-4" className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
)}> <span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} /> <ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && ( {!isCollapsed && !isTextVisible && (
<> <>
<div <div className="grow overflow-hidden">
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p <p
title={user?.email} title={user?.email}
className={cn( className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>} {user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p> </p>
<p className="text-sm text-slate-700">{t("common.account")}</p> <p className="text-sm text-slate-500">{t("common.account")}</p>
</div> </div>
<ChevronRightIcon <ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</> </>
)} )}
</div> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -303,8 +769,6 @@ export const MainNavigation = ({
sideOffset={10} sideOffset={10}
alignOffset={5} alignOffset={5}
align="end"> align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map((link) => (
<Link <Link
href={link.href} href={link.href}
@@ -318,7 +782,6 @@ export const MainNavigation = ({
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
))} ))}
{/* Logout */}
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`; const loginUrl = `${publicDomain}/auth/login`;
@@ -341,6 +804,28 @@ export const MainNavigation = ({
</div> </div>
</aside> </aside>
)} )}
{openProjectLimitModal && (
<ProjectLimitModal
open={openProjectLimitModal}
setOpen={setOpenProjectLimitModal}
buttons={projectLimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</> </>
); );
}; };
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps { interface NavigationLinkProps {
@@ -10,6 +11,8 @@ interface NavigationLinkProps {
children: React.ReactNode; children: React.ReactNode;
linkText: string; linkText: string;
isTextVisible: boolean; isTextVisible: boolean;
disabled?: boolean;
disabledMessage?: string;
} }
export const NavigationLink = ({ export const NavigationLink = ({
@@ -19,10 +22,34 @@ export const NavigationLink = ({
children, children,
linkText, linkText,
isTextVisible = true, isTextVisible = true,
disabled = false,
disabledMessage,
}: NavigationLinkProps) => { }: NavigationLinkProps) => {
const tooltipText = disabled ? disabledMessage || linkText : linkText;
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900"; const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass = const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out"; "hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const getColorClass = (baseClass: string) => {
if (disabled) {
return disabledClass;
}
return cn(baseClass, isActive ? activeClass : inactiveClass);
};
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
const label = (
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
);
return ( return (
<> <>
@@ -30,35 +57,37 @@ export const NavigationLink = ({
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<li <li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
className={cn( {disabled ? (
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900", <div className="flex items-center">{children}</div>
isActive ? activeClass : inactiveClass ) : (
)}> <Link href={href}>{children}</Link>
<Link href={href} className="flex items-center"> )}
{children}
</Link>
</li> </li>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{linkText}</TooltipContent> <TooltipContent side="right">{tooltipText}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : ( ) : (
<li <li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
className={cn( {disabled ? (
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900", <Popover>
isActive ? activeClass : inactiveClass <PopoverTrigger asChild>
)}> <div className="flex items-center">
<Link href={href} className="flex items-center"> {children}
{children} {label}
<span </div>
className={cn( </PopoverTrigger>
"ml-2 flex transition-opacity duration-100", <PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
isTextVisible ? "opacity-0" : "opacity-100" {disabledMessage || linkText}
)}> </PopoverContent>
{linkText} </Popover>
</span> ) : (
</Link> <Link href={href} className="flex items-center">
{children}
{label}
</Link>
)}
</li> </li>
)} )}
</> </>
@@ -31,7 +31,8 @@ export const TopControlBar = ({
isAccessControlAllowed, isAccessControlAllowed,
membershipRole, membershipRole,
}: TopControlBarProps) => { }: TopControlBarProps) => {
const { isMember } = getAccessFlags(membershipRole); const { isMember, isBilling } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const { environment } = useEnvironment(); const { environment } = useEnvironment();
return ( return (
@@ -49,6 +50,8 @@ export const TopControlBar = ({
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isMember={isMember} isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
/> />
</div> </div>
@@ -25,6 +25,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization } from "../context/environment-context"; import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps { interface OrganizationBreadcrumbProps {
@@ -35,6 +36,7 @@ interface OrganizationBreadcrumbProps {
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isMember: boolean; isMember: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
isMembershipPending: boolean;
} }
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => { const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
@@ -56,6 +58,7 @@ export const OrganizationBreadcrumb = ({
isFormbricksCloud, isFormbricksCloud,
isMember, isMember,
isOwnerOrManager, isOwnerOrManager,
isMembershipPending,
}: OrganizationBreadcrumbProps) => { }: OrganizationBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false); const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
@@ -142,7 +145,10 @@ export const OrganizationBreadcrumb = ({
id: "api-keys", id: "api-keys",
label: t("common.api_keys"), label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`, href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager, disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
{ {
id: "domain", id: "domain",
@@ -160,7 +166,11 @@ export const OrganizationBreadcrumb = ({
id: "enterprise", id: "enterprise",
label: t("common.enterprise_license"), label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`, href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isMember, hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
]; ];
@@ -242,14 +252,30 @@ export const OrganizationBreadcrumb = ({
{organizationSettings.map((setting) => { {organizationSettings.map((setting) => {
return setting.hidden ? null : ( return setting.hidden ? null : (
<DropdownMenuCheckboxItem <div key={setting.id}>
key={setting.id} {setting.disabled ? (
checked={isActiveOrganizationSetting(pathname, setting.id)} <Popover>
hidden={setting.hidden} <PopoverTrigger asChild>
onClick={() => handleSettingChange(setting.href)} <button
className="cursor-pointer"> type="button"
{setting.label} aria-disabled="true"
</DropdownMenuCheckboxItem> className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{setting.disabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
); );
})} })}
</div> </div>
@@ -18,6 +18,8 @@ interface ProjectAndOrgSwitchProps {
isLicenseActive: boolean; isLicenseActive: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
isMember: boolean; isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
} }
@@ -35,6 +37,8 @@ export const ProjectAndOrgSwitch = ({
isOwnerOrManager, isOwnerOrManager,
isAccessControlAllowed, isAccessControlAllowed,
isMember, isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => { }: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId); const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development"; const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -50,6 +54,7 @@ export const ProjectAndOrgSwitch = ({
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isMember={isMember} isMember={isMember}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/> />
{currentProjectId && currentEnvironmentId && ( {currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb <ProjectBreadcrumb
@@ -63,6 +68,8 @@ export const ProjectAndOrgSwitch = ({
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb} isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/> />
)} )}
{showEnvironmentBreadcrumb && ( {showEnvironmentBreadcrumb && (
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react"; import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,6 +19,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt"; import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context"; import { useProject } from "../context/environment-context";
@@ -33,6 +34,8 @@ interface ProjectBreadcrumbProps {
currentEnvironmentId: string; currentEnvironmentId: string;
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean; isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
} }
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => { const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
@@ -56,6 +59,8 @@ export const ProjectBreadcrumb = ({
currentEnvironmentId, currentEnvironmentId,
isAccessControlAllowed, isAccessControlAllowed,
isEnvironmentBreadcrumbVisible, isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: ProjectBreadcrumbProps) => { }: ProjectBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false); const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
@@ -134,6 +139,10 @@ export const ProjectBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/workspace/tags`, href: `/environments/${currentEnvironmentId}/workspace/tags`,
}, },
]; ];
const areProjectSettingsDisabled = isMembershipPending || isBilling;
const projectSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentProject) { if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`; const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
@@ -198,7 +207,7 @@ export const ProjectBreadcrumb = ({
id="projectDropdownTrigger" id="projectDropdownTrigger"
asChild> asChild>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} /> <FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span> <span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />} {isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? ( {isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
@@ -211,7 +220,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuContent align="start" className="mt-2"> <DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500"> <div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} /> <FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")} {t("common.choose_workspace")}
</div> </div>
{isLoadingProjects && ( {isLoadingProjects && (
@@ -247,7 +256,24 @@ export const ProjectBreadcrumb = ({
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
{isOwnerOrManager && ( {isMembershipPending || !isOwnerOrManager ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action")}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
onClick={handleAddProject} onClick={handleAddProject}
className="w-full cursor-pointer justify-between"> className="w-full cursor-pointer justify-between">
@@ -264,13 +290,30 @@ export const ProjectBreadcrumb = ({
{t("common.workspace_configuration")} {t("common.workspace_configuration")}
</div> </div>
{projectSettings.map((setting) => ( {projectSettings.map((setting) => (
<DropdownMenuCheckboxItem <div key={setting.id}>
key={setting.id} {areProjectSettingsDisabled ? (
checked={isActiveProjectSetting(pathname, setting.id)} <Popover>
onClick={() => handleProjectSettingsNavigation(setting.id)} <PopoverTrigger asChild>
className="cursor-pointer"> <button
{setting.label} type="button"
</DropdownMenuCheckboxItem> aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{projectSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -12,11 +13,7 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
const { isBilling } = getAccessFlags(currentUserMembership?.role); const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) { if (isBilling) {
if (IS_FORMBRICKS_CLOUD) { return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
return redirect(`/environments/${params.environmentId}/settings/billing`);
} else {
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
}
} }
return redirect(`/environments/${params.environmentId}/surveys`); return redirect(`/environments/${params.environmentId}/surveys`);
@@ -10,15 +10,16 @@ import {
getIsEmailUnique, getIsEmailUnique,
verifyUserPassword, verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return { return {
@@ -85,11 +86,15 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging("passwordReset", "user", async ({ ctx }) => { withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
if (ctx.user.identityProvider !== "email") { if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user."); throw new OperationNotAllowedError("Password reset is not allowed for this user.");
} }
await sendForgotPasswordEmail(ctx.user); await requestPasswordReset(ctx.user, "profile");
ctx.auditLoggingCtx.userId = ctx.user.id; ctx.auditLoggingCtx.userId = ctx.user.id;
@@ -116,10 +116,14 @@ export const EditProfileDetailsForm = ({
setShowModal(true); setShowModal(true);
} else { } else {
try { try {
await updateUserAction({ const result = await updateUserAction({
...data, ...data,
name: data.name.trim(), name: data.name.trim(),
}); });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.settings.profile.profile_updated_successfully")); toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload(); window.location.reload();
form.reset(data); form.reset(data);
@@ -22,8 +22,9 @@ export const OrganizationSettingsNavbar = ({
loading, loading,
}: OrganizationSettingsNavbarProps) => { }: OrganizationSettingsNavbarProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { isMember, isOwner } = getAccessFlags(membershipRole); const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember; const isOwnerOrManager = isOwner || isManager;
const isMembershipPending = membershipRole === undefined || loading;
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = [ const navigation = [
@@ -45,7 +46,10 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"), label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`, href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"), current: pathname?.includes("/api-keys"),
hidden: !isOwner, disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
{ {
id: "domain", id: "domain",
@@ -58,14 +62,18 @@ export const OrganizationSettingsNavbar = ({
id: "billing", id: "billing",
label: t("common.billing"), label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`, href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud || loading, hidden: !isFormbricksCloud,
current: pathname?.includes("/billing"), current: pathname?.includes("/billing"),
}, },
{ {
id: "enterprise", id: "enterprise",
label: t("common.enterprise_license"), label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`, href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isPricingDisabled, hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
current: pathname?.includes("/enterprise"), current: pathname?.includes("/enterprise"),
}, },
]; ];
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { updateOrganizationAISettingsAction } from "./actions";
import { ZOrganizationAISettingsInput } from "./schemas";
const mocks = vi.hoisted(() => ({
isInstanceAIConfigured: vi.fn(),
checkAuthorizationUpdated: vi.fn(),
deleteOrganization: vi.fn(),
getOrganization: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getTranslate: vi.fn(),
updateOrganization: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/lib/organization/service", () => ({
deleteOrganization: mocks.deleteOrganization,
getOrganization: mocks.getOrganization,
updateOrganization: mocks.updateOrganization,
}));
vi.mock("@/lib/ai/service", () => ({
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: mocks.getTranslate,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
const organizationId = "cm9gptbhg0000192zceq9ayuc";
describe("organization AI settings actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key
);
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
test("accepts AI toggle updates", () => {
expect(
ZOrganizationAISettingsInput.parse({
isAISmartToolsEnabled: true,
})
).toEqual({
isAISmartToolsEnabled: true,
});
});
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
const ctx = {
user: { id: "user_1", locale: "en-US" },
auditLoggingCtx: {},
};
const parsedInput = {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
};
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId,
access: [
{
type: "organization",
schema: ZOrganizationAISettingsInput,
data: parsedInput.data,
roles: ["owner", "manager"],
},
],
});
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
expect(ctx.auditLoggingCtx).toMatchObject({
organizationId,
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
test("propagates authorization failures so members cannot update AI settings", async () => {
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_member", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(AuthorizationError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("rejects enabling AI when the instance AI provider is not configured", async () => {
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(OperationNotAllowedError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("allows enabling AI when the instance configuration is valid", async () => {
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: true,
});
});
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: false,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: false,
});
});
});
@@ -2,13 +2,44 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getTranslate } from "@/lingodotdev/server";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({ const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId, organizationId: ZId,
@@ -18,26 +49,114 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction) .inputSchema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => { withAuditLogging(
await checkAuthorizationUpdated({ "updated",
userId: ctx.user.id, "organization",
organizationId: parsedInput.organizationId, async ({
access: [ ctx,
{ parsedInput,
type: "organization", }: {
schema: ZOrganizationUpdateInput.pick({ name: true }), ctx: AuthenticatedActionClientCtx;
data: parsedInput.data, parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
roles: ["owner"], }) =>
}, updateOrganizationAction({
], ctx,
}); organizationId: parsedInput.organizationId,
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; schema: ZOrganizationUpdateInput.pick({ name: true }),
const oldObject = await getOrganization(parsedInput.organizationId); data: parsedInput.data,
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data); roles: ["owner"],
ctx.auditLoggingCtx.oldObject = oldObject; })
ctx.auditLoggingCtx.newObject = result; )
return result; );
})
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
const resolveOrganizationAISettings = ({
data,
organization,
}: {
data: z.infer<typeof ZOrganizationAISettingsInput>;
organization: TOrganizationAISettings;
}): TResolvedOrganizationAISettings => {
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
};
};
const assertOrganizationAISettingsUpdateAllowed = ({
isInstanceAIConfigured,
resolvedSettings,
t,
}: {
isInstanceAIConfigured: boolean;
resolvedSettings: TResolvedOrganizationAISettings;
t: Awaited<ReturnType<typeof getTranslate>>;
}) => {
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
}
};
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) => {
const t = await getTranslate(ctx.user.locale);
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
}
const resolvedSettings = resolveOrganizationAISettings({
data: parsedInput.data,
organization,
});
assertOrganizationAISettingsUpdateAllowed({
isInstanceAIConfigured: isInstanceAIConfigured(),
resolvedSettings,
t,
});
return updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationAISettingsInput,
data: parsedInput.data,
roles: ["owner", "manager"],
});
}
)
); );
const ZDeleteOrganizationAction = z.object({ const ZDeleteOrganizationAction = z.object({
@@ -49,7 +168,10 @@ export const deleteOrganizationAction = authenticatedActionClient
.action( .action(
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => { withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); if (!isMultiOrgEnabled) {
const t = await getTranslate(ctx.user.locale);
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
}
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -0,0 +1,118 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
}: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const aiEnablementState = getOrganizationAIEnablementState({
isInstanceConfigured: isInstanceAIConfigured,
});
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
toast.error(getFormattedErrorMessage(response));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
} finally {
setLoadingField(null);
}
};
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
<Alert variant="warning">
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
</Alert>
)}
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -1,4 +1,5 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
@@ -11,6 +12,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json"; import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip"; import { SecurityListTip } from "./components/SecurityListTip";
@@ -60,6 +62,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role} membershipRole={currentUserMembership?.role}
/> />
</SettingsCard> </SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
/>
</SettingsCard>
<EmailCustomizationSettings <EmailCustomizationSettings
organization={organization} organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission} hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -0,0 +1,13 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationAISettingsInput,
});
@@ -0,0 +1,22 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="" />
<div className="flex h-9 animate-pulse gap-2">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="summary" />
</PageContentWrapper>
);
};
export default Loading;
@@ -29,6 +29,7 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils"; import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -201,7 +202,13 @@ export const ResponseTable = ({
}; };
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => { const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false }); const result = await deleteResponseAction({
responseId,
decrementQuotas: params?.decrementQuotas ?? false,
});
if (result?.serverError) {
throw new Error(getFormattedErrorMessage(result));
}
}; };
// Handle downloading selected responses // Handle downloading selected responses
@@ -0,0 +1,23 @@
"use client";
import { useTranslation } from "react-i18next";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.responses")} />
<div className="flex h-9 animate-pulse gap-1.5">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="responseTable" />
</PageContentWrapper>
);
};
export default Loading;
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")} label={t("environments.surveys.summary.time_to_complete")}
percentage={null} percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`} value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_tooltip")} tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading} isLoading={isLoading}
/> />
@@ -163,6 +163,7 @@ export const PersonalLinksTab = ({
<UpgradePrompt <UpgradePrompt
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")} title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")} description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
feature="personal_links"
buttons={[ buttons={[
{ {
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"), text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
}); });
test("calculates meta correctly", () => { test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas); const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10); expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3); expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30); expect(meta.startsPercentage).toBe(30);
@@ -178,19 +178,74 @@ describe("getSurveySummaryMeta", () => {
}); });
test("handles zero display count", () => { test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas); const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0); expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0); expect(meta.completedPercentage).toBe(0);
}); });
test("handles zero responses", () => { test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10, mockQuotas); const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
expect(meta.totalResponses).toBe(0); expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0); expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0); expect(meta.dropOffCount).toBe(0);
expect(meta.dropOffPercentage).toBe(0); expect(meta.dropOffPercentage).toBe(0);
expect(meta.ttcAverage).toBe(0); expect(meta.ttcAverage).toBe(0);
}); });
test("uses block-level TTC to avoid multiplying by number of elements", () => {
const surveyWithOneBlockThreeElements: TSurvey = {
...mockBaseSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q3",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q3" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
] as TSurveyElement[],
},
],
questions: [],
};
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b", q3: "c" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
finished: true,
},
] as any;
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
expect(meta.ttcAverage).toBe(5000);
});
}); });
describe("getSurveySummaryDropOff", () => { describe("getSurveySummaryDropOff", () => {
@@ -274,7 +329,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2); expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element) expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100 expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms) expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
}); });
test("drop-off attributed to last seen element when user doesn't reach next question", () => { test("drop-off attributed to last seen element when user doesn't reach next question", () => {
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
finished: boolean; finished: boolean;
} }
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = ( export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[], responses: TSurveySummaryResponse[],
displayCount: number, displayCount: number,
quotas: TSurveySummary["quotas"] quotas: TSurveySummary["quotas"]
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0; let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => { const ttcSum = responses.reduce((acc, response) => {
if (response.ttc?._total) { const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
ttcResponseCount++; ttcResponseCount++;
return acc + response.ttc._total; return acc + responseTtcTotal;
} }
return acc; return acc;
}, 0); }, 0);
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[]; let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[]; let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[]; let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => { responses.forEach((response) => {
// Calculate total time-to-completion per element // Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => { Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) { const blockId = elementIdToBlockId[elementId];
totalTtc[elementId] += response.ttc[elementId]; const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
responseCounts[elementId]++; responseCounts[elementId]++;
} }
}); });
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
]); ]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount); const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const [meta, elementSummary] = await Promise.all([ const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
getSurveySummaryMeta(responses, displayCount, quotas), const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
getElementSummary(survey, elements, responses, dropOff),
]);
return { return {
meta, meta,
@@ -1061,7 +1094,9 @@ export const getResponsesForSummary = reactCache(
const transformedResponses: TSurveySummaryResponse[] = await Promise.all( const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => { responses.map((responsePrisma) => {
return { return {
...responsePrisma, id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact contact: responsePrisma.contact
? { ? {
id: responsePrisma.contact.id as string, id: responsePrisma.contact.id as string,
@@ -1070,6 +1105,10 @@ export const getResponsesForSummary = reactCache(
)?.value as string, )?.value as string,
} }
: null, : null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
}; };
}) })
); );
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { capturePostHogEvent } from "@/lib/posthog";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service"; import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
@@ -23,9 +24,11 @@ const ZGetResponsesDownloadUrlAction = z.object({
export const getResponsesDownloadUrlAction = authenticatedActionClient export const getResponsesDownloadUrlAction = authenticatedActionClient
.inputSchema(ZGetResponsesDownloadUrlAction) .inputSchema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
@@ -39,11 +42,20 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
], ],
}); });
return await getResponseDownloadFile( const result = await getResponseDownloadFile(
parsedInput.surveyId, parsedInput.surveyId,
parsedInput.format, parsedInput.format,
parsedInput.filterCriteria parsedInput.filterCriteria
); );
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
return result;
}); });
const ZGetSurveyFilterDataAction = z.object({ const ZGetSurveyFilterDataAction = z.object({
@@ -1,6 +1,7 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import { TFunction } from "i18next";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -54,6 +55,25 @@ export enum OptionsType {
QUOTAS = "Quotas", QUOTAS = "Quotas",
} }
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = { export type ElementOption = {
label: string; label: string;
elementType?: TSurveyElementTypeEnum; elementType?: TSurveyElementTypeEnum;
@@ -218,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => ( {options?.map((data) => (
<Fragment key={data.header}> <Fragment key={data.header}>
{data?.option.length > 0 && ( {data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}> <CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
{data?.option?.map((o) => ( {data?.option?.map((o) => (
<CommandItem <CommandItem
key={o.id} key={o.id}
@@ -1,208 +0,0 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -1,42 +0,0 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
export default Page;
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration"; import { ZIntegrationInput } from "@formbricks/types/integration";
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service"; import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { import {
@@ -45,6 +46,12 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData); const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id; ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result; ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
return result; return result;
}) })
); );
@@ -13,7 +13,9 @@ import notionLogo from "@/images/notion.png";
import SlackLogo from "@/images/slacklogo.png"; import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png"; import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png"; import ZapierLogo from "@/images/zapier-small.png";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation"; import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
@@ -53,7 +55,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
integrations.some((integration) => integration.type === type); integrations.some((integration) => integration.type === type);
if (isBilling) { if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`); return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
} }
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets"); const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { captureSurveyResponsePostHogEvent } from "./posthog";
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
describe("captureSurveyResponsePostHogEvent", () => {
afterEach(() => {
vi.clearAllMocks();
});
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
responseCount,
});
test("fires on 1st response with milestone 'first'", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
});
test("fires on every 100th response", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [100, 200, 300, 500, 1000, 5000]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("does NOT fire for 2nd through 99th responses", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 10, 50, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("does NOT fire for non-100th counts above 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [101, 150, 250, 499, 501]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("sets milestone to count string for non-first milestones", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(200));
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
expect.objectContaining({
is_first_response: false,
milestone: "200",
})
);
});
});
@@ -0,0 +1,33 @@
import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
surveyId: string;
surveyType: string;
environmentId: string;
responseCount: number;
}
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
@@ -51,8 +51,20 @@ vi.mock("@/lib/env", () => ({
RECAPTCHA_SECRET_KEY: "secret-key", RECAPTCHA_SECRET_KEY: "secret-key",
GITHUB_ID: "github-id", GITHUB_ID: "github-id",
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks", SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
ENTERPRISE_LICENSE_KEY: "test-license-key",
}, },
})); }));
vi.mock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.mock("@/lib/hash-string", () => ({
hashString: vi.fn((s: string) => `hashed-${s}`),
}));
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn(),
}));
// Mock fetch // Mock fetch
const fetchMock = vi.fn(); const fetchMock = vi.fn();
@@ -199,6 +211,14 @@ describe("sendTelemetryEvents", () => {
test("should handle telemetry send failure and apply cooldown", async () => { test("should handle telemetry send failure and apply cooldown", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests // Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules(); vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past // Ensure we can acquire lock by setting last sent time far in the past
@@ -221,6 +241,7 @@ describe("sendTelemetryEvents", () => {
expect.objectContaining({ expect.objectContaining({
error: networkError, error: networkError,
message: "Network error", message: "Network error",
hashedLicenseKey: "hashed-test-license-key",
}), }),
"Failed to send telemetry - applying 1h cooldown" "Failed to send telemetry - applying 1h cooldown"
); );
@@ -242,6 +263,14 @@ describe("sendTelemetryEvents", () => {
test("should skip if no organization exists", async () => { test("should skip if no organization exists", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests // Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules(); vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past // Ensure we can acquire lock by setting last sent time far in the past
@@ -276,4 +305,113 @@ describe("sendTelemetryEvents", () => {
// This might be a bug, but we test the actual behavior // This might be a bug, but we test the actual behavior
expect(mockCacheService.set).toHaveBeenCalled(); expect(mockCacheService.set).toHaveBeenCalled();
}); });
test("should skip telemetry when TELEMETRY_DISABLED is true and no active EE license", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: true,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
// Should return early without touching cache or sending telemetry
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should send telemetry when TELEMETRY_DISABLED is true but EE license is active", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: true,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Re-setup mocks after resetModules
vi.mocked(getCacheService).mockResolvedValue({
ok: true,
data: mockCacheService as any,
});
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: "org-123",
createdAt: new Date("2023-01-01"),
} as any);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
organizationCount: BigInt(1),
userCount: BigInt(5),
teamCount: BigInt(2),
projectCount: BigInt(3),
surveyCount: BigInt(10),
inProgressSurveyCount: BigInt(4),
completedSurveyCount: BigInt(6),
responseCountAllTime: BigInt(100),
responseCountSinceLastUpdate: BigInt(10),
displayCount: BigInt(50),
contactCount: BigInt(20),
segmentCount: BigInt(4),
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
},
] as any);
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
fetchMock.mockResolvedValue({ ok: true });
await freshSendTelemetryEvents();
// EE license active — telemetry should bypass TELEMETRY_DISABLED and send
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("should unconditionally skip when E2E_TESTING is true even with active EE license", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: true,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
// E2E_TESTING is a hard skip — no EE bypass, no cache, no fetch
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should unconditionally skip when IS_DEVELOPMENT is true", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: true,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
}); });
@@ -2,8 +2,11 @@ import { IntegrationType } from "@prisma/client";
import { createCacheKey, getCacheService } from "@formbricks/cache"; import { createCacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { E2E_TESTING, IS_DEVELOPMENT, TELEMETRY_DISABLED } from "@/lib/constants";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceInfo } from "@/lib/instance"; import { getInstanceInfo } from "@/lib/instance";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import packageJson from "@/package.json"; import packageJson from "@/package.json";
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -24,8 +27,31 @@ let nextTelemetryCheck = 0;
* 2. Redis check (shared across instances, persists across restarts) * 2. Redis check (shared across instances, persists across restarts)
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments) * 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
*/ */
// Hashed license key for log context — allows correlating log entries to a specific license
// without exposing the raw key. Computed once at module load.
const hashedLicenseKey = env.ENTERPRISE_LICENSE_KEY ? hashString(env.ENTERPRISE_LICENSE_KEY) : null;
/**
* Returns true if telemetry is disabled via env var AND there is no active EE license.
* EE customers cannot opt out — telemetry is always enforced for license compliance.
*/
const isTelemetryDisabledForCE = async (): Promise<boolean> => {
if (!TELEMETRY_DISABLED) return false;
const license = await getEnterpriseLicense();
return !license.active;
};
export const sendTelemetryEvents = async () => { export const sendTelemetryEvents = async () => {
try { try {
// ============================================================
// CHECK 0: Non-Production Hard Skip
// ============================================================
// Purpose: Unconditionally skip telemetry in dev and test/CI environments.
// No EE bypass — these are internal flags, not customer-facing.
if (E2E_TESTING || IS_DEVELOPMENT) {
return;
}
const now = Date.now(); const now = Date.now();
// ============================================================ // ============================================================
@@ -39,7 +65,18 @@ export const sendTelemetryEvents = async () => {
} }
// ============================================================ // ============================================================
// CHECK 2: Redis Check (Shared State) // CHECK 2: Telemetry Disabled Check
// ============================================================
// Purpose: Allow CE self-hosters to opt out of telemetry via env var.
// EE bypass: If an active Enterprise License is detected, telemetry is always sent
// regardless of the TELEMETRY_DISABLED setting to enforce license compliance.
// Placed after in-memory check to avoid calling getEnterpriseLicense() on every invocation.
if (await isTelemetryDisabledForCE()) {
return;
}
// ============================================================
// CHECK 3: Redis Check (Shared State)
// ============================================================ // ============================================================
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster). // Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
// This persists across restarts and works in multi-instance deployments. // This persists across restarts and works in multi-instance deployments.
@@ -66,7 +103,7 @@ export const sendTelemetryEvents = async () => {
} }
// ============================================================ // ============================================================
// CHECK 3: Distributed Lock (Prevent Concurrent Execution) // CHECK 4: Distributed Lock (Prevent Concurrent Execution)
// ============================================================ // ============================================================
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster. // Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
// How it works: // How it works:
@@ -100,7 +137,7 @@ export const sendTelemetryEvents = async () => {
// Log as warning since telemetry is non-essential // Log as warning since telemetry is non-essential
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
logger.warn( logger.warn(
{ error: e, message: errorMessage, lastSent, now }, { error: e, message: errorMessage, lastSent, now, hashedLicenseKey },
"Failed to send telemetry - applying 1h cooldown" "Failed to send telemetry - applying 1h cooldown"
); );
@@ -118,7 +155,7 @@ export const sendTelemetryEvents = async () => {
// Log as warning since telemetry is non-essential functionality // Log as warning since telemetry is non-essential functionality
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn( logger.warn(
{ error, message: errorMessage, timestamp: Date.now() }, { error, message: errorMessage, timestamp: Date.now(), hashedLicenseKey },
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped" "Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
); );
} }
+14 -1
View File
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET } from "@/lib/constants"; import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto"; import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -24,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups"; import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations"; import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => { export const POST = async (request: Request) => {
const requestHeaders = await headers(); const requestHeaders = await headers();
@@ -299,6 +300,18 @@ export const POST = async (request: Request) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event"); logger.error({ error, responseId: response.id }, "Failed to record response meter event");
}); });
if (POSTHOG_KEY) {
const responseCount = await getResponseCountBySurveyId(surveyId);
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
surveyId,
surveyType: survey.type,
environmentId,
responseCount,
});
}
// Send telemetry events // Send telemetry events
await sendTelemetryEvents(); await sendTelemetryEvents();
} }
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
type WrappedAuthOptions = {
callbacks: {
signIn: (params: { user: unknown; account: unknown }) => Promise<boolean | string>;
};
events: {
signIn: (params: { user: unknown; account: unknown; isNewUser: boolean }) => Promise<void>;
};
};
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn((_authOptions: WrappedAuthOptions) => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123"): Promise<WrappedAuthOptions> => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
const firstCall = mocks.nextAuth.mock.calls.at(0);
if (!firstCall) {
throw new Error("NextAuth was not called");
}
const [authOptions] = firstCall;
if (!authOptions) {
throw new Error("NextAuth options were not provided");
}
return authOptions;
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
test("logs blocked SSO account-linking attempts as SSO failures", async () => {
const error = new Error("OAuthAccountNotLinked");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-sso-failure");
const user = { id: "user_3", email: "user3@example.com" };
const account = { provider: "google" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("OAuthAccountNotLinked");
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_3",
targetId: "user_3",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-sso-failure",
newObject: expect.objectContaining({
email: "user3@example.com",
authMethod: "sso",
provider: "google",
errorMessage: "OAuthAccountNotLinked",
}),
})
);
});
});
+67 -68
View File
@@ -6,10 +6,26 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions"; import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store"; export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => { const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined; const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -17,44 +33,6 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions, ...baseAuthOptions,
callbacks: { callbacks: {
...baseAuthOptions.callbacks, ...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) { async session(params: any) {
let result: any = params.session; let result: any = params.session;
let error: any = undefined; let error: any = undefined;
@@ -90,7 +68,7 @@ const handler = async (req: Request, ctx: any) => {
}) { }) {
let result: boolean | string = true; let result: boolean | string = true;
let error: any = undefined; let error: any = undefined;
let authMethod = "unknown"; const authMethod = getAuthMethod(account);
try { try {
if (baseAuthOptions.callbacks?.signIn) { if (baseAuthOptions.callbacks?.signIn) {
@@ -102,15 +80,6 @@ const handler = async (req: Request, ctx: any) => {
credentials, credentials,
}); });
} }
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) { } catch (err) {
error = err; error = err;
result = false; result = false;
@@ -122,30 +91,60 @@ const handler = async (req: Request, ctx: any) => {
} }
} }
const status: TAuditStatus = result === false ? "failure" : "success"; if (result === false) {
const auditLog = { queueAuditEventBackground({
action: "signedIn" as const, action: "signedIn",
targetType: "user" as const, targetType: "user",
userId: user?.id ?? UNKNOWN_DATA, userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA, targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA, organizationId: UNKNOWN_DATA,
status, status: "failure",
userType: "user" as const, userType: "user",
newObject: { newObject: {
...user, ...user,
authMethod, authMethod,
provider: account?.provider, provider: account?.provider,
...(error ? { errorMessage: error.message } : {}), ...(error instanceof Error ? { errorMessage: error.message } : {}),
}, },
...(status === "failure" ? { eventId } : {}), eventId,
}; });
}
queueAuditEventBackground(auditLog);
if (error) throw error; if (error) throw error;
return result; return result;
}, },
}, },
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
newObject: {
...user,
authMethod: getAuthMethod(account),
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
},
});
},
},
}; };
return NextAuth(authOptions)(req, ctx); return NextAuth(authOptions)(req, ctx);
@@ -1,5 +1,6 @@
import { google } from "googleapis"; import { google } from "googleapis";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet"; import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { import {
@@ -10,6 +11,8 @@ import {
} from "@/lib/constants"; } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service"; import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => { export const GET = async (req: Request) => {
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration); const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) { if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect( return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets` `${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
); );
@@ -70,6 +70,7 @@ const mockEnvironmentData = {
displayOption: "displayOnce", displayOption: "displayOnce",
hiddenFields: { enabled: false }, hiddenFields: { enabled: false },
isBackButtonHidden: false, isBackButtonHidden: false,
isAutoProgressingEnabled: true,
triggers: [], triggers: [],
displayPercentage: null, displayPercentage: null,
delay: 0, delay: 0,
@@ -122,6 +123,13 @@ describe("getEnvironmentStateData", () => {
surveys: expect.any(Object), surveys: expect.any(Object),
}), }),
}); });
const prismaCall = vi.mocked(prisma.environment.findUnique).mock.calls[0][0];
expect(prismaCall.select.surveys.select).toEqual(
expect.objectContaining({
isAutoProgressingEnabled: true,
})
);
}); });
test("should throw ResourceNotFoundError when environment is not found", async () => { test("should throw ResourceNotFoundError when environment is not found", async () => {
@@ -121,6 +121,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
displayOption: true, displayOption: true,
hiddenFields: true, hiddenFields: true,
isBackButtonHidden: true, isBackButtonHidden: true,
isAutoProgressingEnabled: true,
triggers: { triggers: {
select: { select: {
actionClass: { actionClass: {
@@ -6,6 +6,7 @@ import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/typ
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { capturePostHogEvent } from "@/lib/posthog";
import { EnvironmentStateData, getEnvironmentStateData } from "./data"; import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState"; import { getEnvironmentState } from "./environmentState";
@@ -36,6 +37,11 @@ vi.mock("@/lib/constants", () => ({
IS_RECAPTCHA_CONFIGURED: true, IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true, IS_PRODUCTION: true,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key", ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
POSTHOG_KEY: "phc_test_key",
}));
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
})); }));
// Mock @formbricks/cache // Mock @formbricks/cache
@@ -76,7 +82,8 @@ const mockOrganization: TOrganization = {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
}; };
const mockSurveys: TSurvey[] = [ const mockSurveys: TSurvey[] = [
@@ -302,4 +309,38 @@ describe("getEnvironmentState", () => {
expect(result.data.actionClasses).toEqual([]); expect(result.data.actionClasses).toEqual([]);
}); });
test("should capture app_connected PostHog event when app setup completes", async () => {
const noCodeAction = {
...mockActionClasses[0],
id: "action-2",
type: "noCode" as const,
key: null,
};
const incompleteEnvironmentData = {
...mockEnvironmentStateData,
environment: {
...mockEnvironmentStateData.environment,
appSetupCompleted: false,
},
actionClasses: [...mockActionClasses, noCodeAction],
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
await getEnvironmentState(environmentId);
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
});
});
test("should not capture app_connected event when app setup already completed", async () => {
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
await getEnvironmentState(environmentId);
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
}); });
@@ -3,7 +3,8 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TJsEnvironmentState } from "@formbricks/types/js"; import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants"; import { IS_RECAPTCHA_CONFIGURED, POSTHOG_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { getEnvironmentStateData } from "./data"; import { getEnvironmentStateData } from "./data";
/** /**
@@ -30,6 +31,14 @@ export const getEnvironmentState = async (
where: { id: environmentId }, where: { id: environmentId },
data: { appSetupCompleted: true }, data: { appSetupCompleted: true },
}); });
if (POSTHOG_KEY) {
capturePostHogEvent(environmentId, "app_connected", {
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
});
}
} }
// Build the response data // Build the response data
@@ -86,9 +86,11 @@ export const GET = withV1ApiWrapper({
}; };
} }
const error = err instanceof Error ? err : new Error(String(err));
logger.error( logger.error(
{ {
error: err, error,
url: req.url, url: req.url,
environmentId: params.environmentId, environmentId: params.environmentId,
}, },
@@ -96,9 +98,10 @@ export const GET = withV1ApiWrapper({
); );
return { return {
response: responses.internalServerErrorResponse( response: responses.internalServerErrorResponse(
err instanceof Error ? err.message : "Unknown error occurred", "An error occurred while processing your request.",
true true
), ),
error,
}; };
} }
}, },
@@ -0,0 +1,488 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { putResponseHandler } from "./put-response-handler";
const mocks = vi.hoisted(() => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
getResponse: vi.fn(),
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/response/service", () => ({
getResponse: mocks.getResponse,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/api/v2/lib/element", () => ({
validateOtherOptionLengthForMultipleChoice: mocks.validateOtherOptionLengthForMultipleChoice,
}));
vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mocks.validateFileUploads,
}));
vi.mock("./response", () => ({
updateResponseWithQuotaEvaluation: mocks.updateResponseWithQuotaEvaluation,
}));
vi.mock("./validated-response-update-input", () => ({
getValidatedResponseUpdateInput: mocks.getValidatedResponseUpdateInput,
}));
const environmentId = "environment_a";
const responseId = "response_123";
const surveyId = "survey_123";
const createRequest = () =>
new Request(`https://api.test/api/v1/client/${environmentId}/responses/${responseId}`, {
method: "PUT",
});
const createHandlerParams = (params?: Partial<{ environmentId: string; responseId: string }>) =>
({
req: createRequest(),
props: {
params: Promise.resolve({
environmentId,
responseId,
...params,
}),
},
}) as never;
const getBaseResponseUpdateInput = () => ({
data: {
q1: "updated-answer",
},
language: "en",
});
const getBaseExistingResponse = () =>
({
id: responseId,
surveyId,
data: {
q0: "existing-answer",
},
finished: false,
language: "en",
}) as const;
const getBaseSurvey = () =>
({
id: surveyId,
environmentId,
blocks: [],
questions: [],
}) as const;
const getBaseUpdatedResponse = () =>
({
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: false,
quotaFull: undefined,
}) as const;
describe("putResponseHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
responseUpdateInput: getBaseResponseUpdateInput(),
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
});
test("returns a bad request response when the response id is missing", async () => {
const result = await putResponseHandler(createHandlerParams({ responseId: "" }));
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response ID is missing",
details: {},
});
expect(mocks.getValidatedResponseUpdateInput).not.toHaveBeenCalled();
});
test("returns the validation response from the parsed request input", async () => {
const validationResponse = responses.badRequestResponse(
"Malformed JSON in request body",
undefined,
true
);
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
response: validationResponse,
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response).toBe(validationResponse);
expect(mocks.getResponse).not.toHaveBeenCalled();
});
test("returns not found when the response does not exist", async () => {
mocks.getResponse.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("maps resource lookup errors to a not found response", async () => {
mocks.getResponse.mockRejectedValue(new ResourceNotFoundError("Response", responseId));
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("maps invalid lookup input errors to a bad request response", async () => {
mocks.getResponse.mockRejectedValue(new InvalidInputError("Invalid response id"));
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Invalid response id",
details: {},
});
});
test("maps database lookup errors to a reported internal server error", async () => {
const error = new DatabaseError("Lookup failed");
mocks.getResponse.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Lookup failed",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("maps unknown lookup failures to a generic internal server error", async () => {
const error = new Error("boom");
mocks.getResponse.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Unknown error occurred",
details: {},
});
});
test("rejects updates when the response survey does not belong to the requested environment", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
environmentId: "different_environment",
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("rejects updates when the response is already finished", async () => {
mocks.getResponse.mockResolvedValue({
...getBaseExistingResponse(),
finished: true,
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response is already finished",
details: {},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("rejects invalid file upload updates", async () => {
mocks.validateFileUploads.mockReturnValue(false);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Invalid file upload response",
details: {},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("rejects updates when an other-option response exceeds the character limit", async () => {
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue("question_123");
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response exceeds character limit",
details: {
questionId: "question_123",
},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("returns validation details when merged response data is invalid", async () => {
mocks.validateResponseData.mockReturnValue([{ field: "q1", message: "Required" }]);
mocks.formatValidationErrorsForV1Api.mockReturnValue({
q1: "Required",
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Validation failed",
details: {
q1: "Required",
},
});
expect(mocks.formatValidationErrorsForV1Api).toHaveBeenCalledWith([{ field: "q1", message: "Required" }]);
});
test("returns not found when the response disappears during update", async () => {
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
new ResourceNotFoundError("Response", responseId)
);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("returns a bad request response for invalid update input during persistence", async () => {
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
new InvalidInputError("Response update payload is invalid")
);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response update payload is invalid",
details: {},
});
});
test("returns a reported internal server error for database update failures", async () => {
const error = new DatabaseError("Update failed");
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Update failed",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("returns a generic internal server error for unexpected update failures", async () => {
const error = new Error("Unexpected persistence failure");
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Something went wrong",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("returns a success payload and emits a responseUpdated pipeline event", async () => {
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
data: {
id: responseId,
quotaFull: false,
},
});
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(1);
expect(mocks.sendToPipeline).toHaveBeenCalledWith({
event: "responseUpdated",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: false,
},
});
});
test("emits both pipeline events and includes quota metadata when the response finishes", async () => {
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue({
...getBaseUpdatedResponse(),
finished: true,
quotaFull: {
id: "quota_123",
action: "endSurvey",
endingCardId: "ending_card_123",
},
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
data: {
id: responseId,
quotaFull: true,
quota: {
id: "quota_123",
action: "endSurvey",
endingCardId: "ending_card_123",
},
},
});
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(2);
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(1, {
event: "responseUpdated",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: true,
},
});
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(2, {
event: "responseFinished",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: true,
},
});
});
});
@@ -0,0 +1,283 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { THandlerParams } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./response";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
type TRouteResult = {
response: Response;
error?: unknown;
};
type TExistingResponseResult = { existingResponse: TResponse } | TRouteResult;
type TSurveyResult = { survey: TSurvey } | TRouteResult;
type TUpdatedResponseResult =
| { updatedResponse: Awaited<ReturnType<typeof updateResponseWithQuotaEvaluation>> }
| TRouteResult;
export type TPutRouteParams = {
params: Promise<{
environmentId: string;
responseId: string;
}>;
};
const handleDatabaseError = (
error: Error,
url: string,
endpoint: string,
responseId: string
): TRouteResult => {
if (error instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse("Response", responseId, true) };
}
if (error instanceof InvalidInputError) {
return { response: responses.badRequestResponse(error.message, undefined, true) };
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return {
response: responses.internalServerErrorResponse(error.message, true),
error,
};
}
return {
response: responses.internalServerErrorResponse("Unknown error occurred", true),
error,
};
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
const getExistingResponse = async (req: Request, responseId: string): Promise<TExistingResponseResult> => {
try {
const existingResponse = await getResponse(responseId);
return existingResponse
? { existingResponse }
: { response: responses.notFoundResponse("Response", responseId, true) };
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
responseId
);
}
};
const getSurveyForResponse = async (
req: Request,
responseId: string,
surveyId: string
): Promise<TSurveyResult> => {
try {
const survey = await getSurvey(surveyId);
return survey ? { survey } : { response: responses.notFoundResponse("Survey", surveyId, true) };
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
responseId
);
}
};
const validateUpdateRequest = (
existingResponse: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
): TRouteResult | undefined => {
if (existingResponse.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseUpdateInput.data,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: responseUpdateInput.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
return validateResponse(existingResponse, survey, responseUpdateInput);
};
const getUpdatedResponse = async (
req: Request,
responseId: string,
responseUpdateInput: TResponseUpdateInput
): Promise<TUpdatedResponseResult> => {
try {
const updatedResponse = await updateResponseWithQuotaEvaluation(responseId, responseUpdateInput);
return { updatedResponse };
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
error,
};
}
const unexpectedError = error instanceof Error ? error : new Error(String(error));
logger.error(
{ error: unexpectedError, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
error: unexpectedError,
};
}
};
export const putResponseHandler = async ({
req,
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { environmentId, responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
if ("response" in validatedUpdateInput) {
return validatedUpdateInput;
}
const { responseUpdateInput } = validatedUpdateInput;
const existingResponseResult = await getExistingResponse(req, responseId);
if ("response" in existingResponseResult) {
return existingResponseResult;
}
const { existingResponse } = existingResponseResult;
const surveyResult = await getSurveyForResponse(req, responseId, existingResponse.surveyId);
if ("response" in surveyResult) {
return surveyResult;
}
const { survey } = surveyResult;
if (survey.environmentId !== environmentId) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
if (validationResult) {
return validationResult;
}
const updatedResponseResult = await getUpdatedResponse(req, responseId, responseUpdateInput);
if ("response" in updatedResponseResult) {
return updatedResponseResult;
}
const { updatedResponse } = updatedResponseResult;
const { quotaFull, ...responseData } = updatedResponse;
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
if (updatedResponse.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
};
@@ -0,0 +1,84 @@
import { describe, expect, test } from "vitest";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
describe("getValidatedResponseUpdateInput", () => {
test("returns a bad request response for malformed JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: "{invalid-json",
});
const result = await getValidatedResponseUpdateInput(request);
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual(
expect.objectContaining({
code: "bad_request",
message: "Malformed JSON in request body",
details: {
error: expect.any(String),
},
})
);
});
test("returns parsed response update input for valid JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: true,
}),
});
const result = await getValidatedResponseUpdateInput(request);
expect(result).toEqual({
responseUpdateInput: {
finished: true,
},
});
});
test("returns a bad request response for schema-invalid JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: "not-boolean",
}),
});
const result = await getValidatedResponseUpdateInput(request);
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual(
expect.objectContaining({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: expect.objectContaining({
finished: expect.any(String),
}),
})
);
});
});
@@ -0,0 +1,28 @@
import { TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import {
TParseAndValidateJsonBodyResult,
parseAndValidateJsonBody,
} from "@/app/lib/api/parse-and-validate-json-body";
export type TValidatedResponseUpdateInputResult =
| { response: Response }
| { responseUpdateInput: TResponseUpdateInput };
export const getValidatedResponseUpdateInput = async (
req: Request
): Promise<TValidatedResponseUpdateInputResult> => {
const validatedInput: TParseAndValidateJsonBodyResult<TResponseUpdateInput> =
await parseAndValidateJsonBody({
request: req,
schema: ZResponseUpdateInput,
malformedJsonMessage: "Malformed JSON in request body",
});
if ("response" in validatedInput) {
return {
response: validatedInput.response,
};
}
return { responseUpdateInput: validatedInput.data };
};
@@ -1,235 +1,11 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { putResponseHandler } from "./lib/put-response-handler";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => { export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true); return responses.successResponse({}, true);
}; };
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return responses.internalServerErrorResponse(error.message, true);
}
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({ export const PUT = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => { handler: putResponseHandler,
const params = await props.params;
const { responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const responseUpdate = await req.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!response) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", response.surveyId, true),
};
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return {
response: 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 as unknown as TSurveyElement[],
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
};
}
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
};
}
const { quotaFull, ...responseData } = updatedResponse;
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
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: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
},
}); });
@@ -1,7 +1,7 @@
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage"; import { ZUploadPrivateFileRequest } from "@formbricks/types/storage";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants"; import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -30,33 +30,27 @@ export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => { handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params; const params = await props.params;
const { environmentId } = params; const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest; const parsedInputResult = await parseAndValidateJsonBody({
request: req,
try { schema: ZUploadPrivateFileRequest,
jsonInput = await req.json(); buildInput: (jsonInput) => ({
} catch (error) { ...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
logger.error({ error, url: req.url }, "Error parsing JSON input"); environmentId,
return { }),
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
...jsonInput,
environmentId,
}); });
if (!parsedInputResult.success) { if ("response" in parsedInputResult) {
const errorDetails = transformErrorToDetails(parsedInputResult.error); if (parsedInputResult.issue === "invalid_json") {
logger.error({ error: parsedInputResult.details, url: req.url }, "Error parsing JSON input");
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted"); } else {
logger.error(
{ error: parsedInputResult.details, url: req.url },
"Fields are missing or incorrectly formatted"
);
}
return { return {
response: responses.badRequestResponse( response: parsedInputResult.response,
"Fields are missing or incorrectly formatted",
errorDetails,
true
),
}; };
} }
@@ -105,9 +99,14 @@ export const POST = withV1ApiWrapper({
if (!signedUrlResponse.ok) { if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload"); logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName }); const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return { return errorResponse.status >= 500
response: errorResponse, ? {
}; response: errorResponse,
error: signedUrlResponse.error,
}
: {
response: errorResponse,
};
} }
return { return {
@@ -6,6 +6,8 @@ import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service"; import { createOrUpdateIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => { const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", { const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -86,6 +88,17 @@ export const GET = withV1ApiWrapper({
}, },
}; };
await createOrUpdateIntegration(environmentId, airtableIntegrationInput); await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
return { return {
response: Response.redirect( response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable` `${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion"; import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -11,6 +12,8 @@ import {
import { symmetricEncrypt } from "@/lib/crypto"; import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service"; import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({ export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => { handler: async ({ req, authentication }) => {
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, notionIntegration); const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) { if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
return { return {
response: Response.redirect( response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion` `${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { import {
TIntegrationSlackConfig, TIntegrationSlackConfig,
TIntegrationSlackConfigData, TIntegrationSlackConfigData,
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants"; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service"; import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({ export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => { handler: async ({ req, authentication }) => {
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, integration); const result = await createOrUpdateIntegration(environmentId, integration);
if (result) { if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
return { return {
response: Response.redirect( response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack` `${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
@@ -49,7 +49,8 @@ const mockOrganization: TOrganization = {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
}; };
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = { const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromEnvironmentId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});
@@ -1,14 +1,12 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { import {
applyPublicIpRateLimit, TDisplayCreateInputV2,
publicEdgeRateLimitPolicies, ZDisplayCreateInputV2,
} from "@/modules/core/rate-limit/public-edge-rate-limit"; } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display"; import { createDisplay } from "./lib/display";
@@ -18,6 +16,29 @@ interface Context {
}>; }>;
} }
type TValidatedDisplayInputResult = { displayInputData: TDisplayCreateInputV2 } | { response: Response };
const parseAndValidateDisplayInput = async (
request: Request,
environmentId: string
): Promise<TValidatedDisplayInputResult> => {
const inputValidation = await parseAndValidateJsonBody({
request,
schema: ZDisplayCreateInputV2,
buildInput: (jsonInput) => ({
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
environmentId,
}),
malformedJsonMessage: "Invalid JSON in request body",
});
if ("response" in inputValidation) {
return inputValidation;
}
return { displayInputData: inputValidation.data };
};
export const OPTIONS = async (): Promise<Response> => { export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse( return responses.successResponse(
{}, {},
@@ -29,48 +50,41 @@ export const OPTIONS = async (): Promise<Response> => {
}; };
export const POST = async (request: Request, context: Context): Promise<Response> => { export const POST = async (request: Request, context: Context): Promise<Response> => {
try {
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientDisplays, rateLimitConfigs.api.client);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
);
}
const params = await context.params; const params = await context.params;
const jsonInput = await request.json(); const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
if (!inputValidation.success) { if ("response" in validatedInput) {
return responses.badRequestResponse( return validatedInput.response;
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
} }
if (inputValidation.data.contactId) { const { displayInputData } = validatedInput;
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
try { try {
const response = await createDisplay(inputValidation.data); if (displayInputData.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
);
}
}
const response = await createDisplay(displayInputData);
return responses.successResponse(response, true); return responses.successResponse(response, true);
} catch (error) { } catch (error) {
if (error instanceof ResourceNotFoundError) { if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId); return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
} else {
logger.error({ error, url: request.url }, "Error creating display");
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
} }
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
status: response.status,
error,
});
return response;
} }
}; };
@@ -0,0 +1,135 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getEnvironmentState: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[environmentId]/environment/lib/environmentState", () => ({
getEnvironmentState: mocks.getEnvironmentState,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getEnvironmentState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("./route");
const response = await GET(request, {
params: Promise.resolve({
environmentId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,10 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment"; import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors"; import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota"; import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils"; import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
@@ -14,11 +14,6 @@ import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation"; import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response"; import { createResponseWithQuotaEvaluation } from "./lib/response";
@@ -30,87 +25,86 @@ interface Context {
}>; }>;
} }
export const OPTIONS = async (): Promise<Response> => { type TResponseSurvey = NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
return responses.successResponse(
{},
true,
// Cache CORS preflight responses for 1 hour (conservative approach)
// Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => { type TValidatedResponseInputResult =
try { | {
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientResponses, rateLimitConfigs.api.client); environmentId: string;
} catch (error) { responseInputData: TResponseInputV2;
return responses.tooManyRequestsResponse( }
error instanceof Error ? error.message : "Rate limit exceeded", | { response: Response };
true
);
}
const params = await context.params; const getCountry = (requestHeaders: Headers): string | undefined =>
const requestHeaders = await headers(); requestHeaders.get("CF-IPCountry") ||
let responseInput; requestHeaders.get("X-Vercel-IP-Country") ||
try { requestHeaders.get("CloudFront-Viewer-Country") ||
responseInput = await request.json(); undefined;
} catch (error) {
return responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
);
}
const { environmentId } = params; const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
const parseAndValidateResponseInput = async (
request: Request,
environmentId: string
): Promise<TValidatedResponseInputResult> => {
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId); const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) { if (!environmentIdValidation.success) {
return responses.badRequestResponse( return {
"Fields are missing or incorrectly formatted", response: responses.badRequestResponse(
transformErrorToDetails(environmentIdValidation.error), "Fields are missing or incorrectly formatted",
true transformErrorToDetails(environmentIdValidation.error),
); true
),
};
} }
if (!responseInputValidation.success) { const responseInputValidation = await parseAndValidateJsonBody({
return responses.badRequestResponse( request,
"Fields are missing or incorrectly formatted", schema: ZResponseInputV2,
transformErrorToDetails(responseInputValidation.error), buildInput: (jsonInput) => ({
true ...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
); environmentId,
}),
malformedJsonMessage: "Invalid JSON in request body",
});
if ("response" in responseInputValidation) {
return responseInputValidation;
} }
const userAgent = request.headers.get("user-agent") || undefined; return {
const agent = new UAParser(userAgent); environmentId,
responseInputData: responseInputValidation.data,
};
};
const country = const getContactsDisabledResponse = async (
requestHeaders.get("CF-IPCountry") || environmentId: string,
requestHeaders.get("X-Vercel-IP-Country") || contactId: string | null | undefined
requestHeaders.get("CloudFront-Viewer-Country") || ): Promise<Response | null> => {
undefined; if (!contactId) {
return null;
const responseInputData = responseInputValidation.data;
if (responseInputData.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
} }
// get and check survey const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const survey = await getSurvey(responseInputData.surveyId); const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true); return isContactsEnabled
} ? null
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput); : responses.forbiddenResponse("User identification is only available for enterprise users.", true);
if (surveyCheckResult) return surveyCheckResult; };
const validateResponseSubmission = async (
environmentId: string,
responseInputData: TResponseInputV2,
survey: TResponseSurvey
): Promise<Response | null> => {
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInputData);
if (surveyCheckResult) {
return surveyCheckResult;
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({ const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data, responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks), surveyQuestions: getElementsFromBlocks(survey.blocks),
@@ -127,7 +121,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
); );
} }
// Validate response data against validation rules
const validationErrors = validateResponseData( const validationErrors = validateResponseData(
survey.blocks, survey.blocks,
responseInputData.data, responseInputData.data,
@@ -135,15 +128,29 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.questions survey.questions
); );
if (validationErrors) { return validationErrors
return responses.badRequestResponse( ? responses.badRequestResponse(
"Validation failed", "Validation failed",
formatValidationErrorsForV1Api(validationErrors), formatValidationErrorsForV1Api(validationErrors),
true true
); )
} : null;
};
const createResponseForRequest = async ({
request,
survey,
responseInputData,
country,
}: {
request: Request;
survey: TResponseSurvey;
responseInputData: TResponseInputV2;
country: string | undefined;
}): Promise<TResponseWithQuotaFull | Response> => {
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
let response: TResponseWithQuotaFull;
try { try {
const meta: TResponseInputV2["meta"] = { const meta: TResponseInputV2["meta"] = {
source: responseInputData?.meta?.source, source: responseInputData?.meta?.source,
@@ -153,54 +160,115 @@ export const POST = async (request: Request, context: Context): Promise<Response
device: agent.getDevice().type || "desktop", device: agent.getDevice().type || "desktop",
os: agent.getOS().name, os: agent.getOS().name,
}, },
country: country, country,
action: responseInputData?.meta?.action, action: responseInputData?.meta?.action,
}; };
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) { if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders(); meta.ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
} }
response = await createResponseWithQuotaEvaluation({ return await createResponseWithQuotaEvaluation({
...responseInputData, ...responseInputData,
meta, meta,
}); });
} catch (error) { } catch (error) {
if (error instanceof InvalidInputError) { if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message); return responses.badRequestResponse(error.message, undefined, true);
} }
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse( const response = getUnexpectedPublicErrorResponse();
error instanceof Error ? error.message : "Unknown error occurred" reportApiError({
); request,
status: response.status,
error,
});
return response;
} }
const { quotaFull, ...responseData } = response; };
sendToPipeline({ export const OPTIONS = async (): Promise<Response> => {
event: "responseCreated", return responses.successResponse(
environmentId, {},
surveyId: responseData.surveyId, true,
response: responseData, // Cache CORS preflight responses for 1 hour (conservative approach)
}); // Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const validatedInput = await parseAndValidateResponseInput(request, params.environmentId);
if ("response" in validatedInput) {
return validatedInput.response;
}
const { environmentId, responseInputData } = validatedInput;
const country = getCountry(request.headers);
try {
const contactsDisabledResponse = await getContactsDisabledResponse(
environmentId,
responseInputData.contactId
);
if (contactsDisabledResponse) {
return contactsDisabledResponse;
}
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
const validationResponse = await validateResponseSubmission(environmentId, responseInputData, survey);
if (validationResponse) {
return validationResponse;
}
const createdResponse = await createResponseForRequest({
request,
survey,
responseInputData,
country,
});
if (createdResponse instanceof Response) {
return createdResponse;
}
const { quotaFull, ...responseData } = createdResponse;
if (responseData.finished) {
sendToPipeline({ sendToPipeline({
event: "responseFinished", event: "responseCreated",
environmentId, environmentId,
surveyId: responseData.surveyId, surveyId: responseData.surveyId,
response: responseData, response: responseData,
}); });
if (responseData.finished) {
sendToPipeline({
event: "responseFinished",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
} catch (error) {
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
status: response.status,
error,
});
return response;
} }
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
}; };
@@ -0,0 +1,221 @@
import * as Sentry from "@sentry/nextjs";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { reportApiError } from "./api-error-reporter";
const loggerMocks = vi.hoisted(() => {
const contextualError = vi.fn();
const rootError = vi.fn();
const withContext = vi.fn(() => ({
error: contextualError,
}));
return {
contextualError,
rootError,
withContext,
};
});
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: loggerMocks.withContext,
error: loggerMocks.rootError,
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
};
});
describe("reportApiError", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("captures real errors directly with structured context", () => {
const request = new Request("https://app.test/api/v2/client/environment", {
method: "POST",
headers: {
"x-request-id": "req-1",
},
});
const error = new Error("boom");
reportApiError({
request,
status: 500,
error,
});
expect(loggerMocks.withContext).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
status: 500,
error: expect.objectContaining({
name: "Error",
message: "boom",
}),
})
);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "boom",
}),
originalError: expect.objectContaining({
name: "Error",
message: "boom",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
status: 500,
}),
}),
})
);
});
test("captures non-error payloads with a synthetic error while preserving additional data", () => {
const request = new Request("https://app.test/api/v1/management/surveys", {
headers: {
"x-request-id": "req-2",
},
});
const payload = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
reportApiError({
request,
status: 500,
error: payload,
originalError: payload,
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V1 error, id: req-2",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "req-2",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: payload,
originalError: payload,
}),
})
);
});
test("swallows Sentry failures after logging a fallback reporter error", () => {
vi.mocked(Sentry.captureException).mockImplementation(() => {
throw new Error("sentry down");
});
const request = new Request("https://app.test/api/v2/client/displays", {
headers: {
"x-request-id": "req-3",
},
});
expect(() =>
reportApiError({
request,
status: 500,
error: new Error("boom"),
})
).not.toThrow();
expect(loggerMocks.rootError).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: "v2",
correlationId: "req-3",
method: "GET",
path: "/api/v2/client/displays",
status: 500,
reportingError: expect.objectContaining({
name: "Error",
message: "sentry down",
}),
}),
"Failed to report API error"
);
});
test("serializes cyclic payloads without throwing", () => {
const request = new Request("https://app.test/api/v2/client/responses", {
headers: {
"x-request-id": "req-4",
},
});
const payload: Record<string, unknown> = {
type: "internal_server_error",
};
payload.self = payload;
expect(() =>
reportApiError({
request,
status: 500,
error: payload,
originalError: payload,
})
).not.toThrow();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: req-4",
}),
expect.objectContaining({
extra: expect.objectContaining({
error: {
type: "internal_server_error",
self: "[Circular]",
},
originalError: {
type: "internal_server_error",
self: "[Circular]",
},
}),
})
);
});
});
+282
View File
@@ -0,0 +1,282 @@
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
type TRequestLike = Pick<Request, "method" | "url" | "headers">;
type TApiErrorContext = {
apiVersion: TApiVersion;
correlationId: string;
method: string;
path: string;
status: number;
};
type TSentryCaptureContext = NonNullable<Parameters<typeof Sentry.captureException>[1]>;
export type TApiVersion = "v1" | "v2" | "v3" | "unknown";
const getPathname = (url: string): string => {
if (url.startsWith("/")) {
return url;
}
try {
return new URL(url).pathname;
} catch {
return url;
}
};
export const getApiVersionFromPath = (pathname: string): TApiVersion => {
const match = /^\/api\/(v\d+)(?:\/|$)/.exec(pathname);
if (!match) {
return "unknown";
}
switch (match[1]) {
case "v1":
case "v2":
case "v3":
return match[1];
default:
return "unknown";
}
};
const serializeError = (value: unknown, seen = new WeakSet<object>()): unknown => {
if (value === null || value === undefined) {
return value;
}
if (typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
if (value instanceof Error) {
const serializedError: Record<string, unknown> = {
name: value.name,
message: value.message,
};
if (value.stack) {
serializedError.stack = value.stack;
}
if ("cause" in value && value.cause !== undefined) {
serializedError.cause = serializeError(value.cause, seen);
}
for (const [key, entryValue] of Object.entries(value as unknown as Record<string, unknown>)) {
serializedError[key] = serializeError(entryValue, seen);
}
return serializedError;
}
if (Array.isArray(value)) {
return value.map((item) => serializeError(item, seen));
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
key,
serializeError(entryValue, seen),
])
);
};
const getSerializedValueType = (value: unknown): string => {
if (value === null) {
return "null";
}
if (Array.isArray(value)) {
return "array";
}
if (value instanceof Error) {
return value.name;
}
return typeof value;
};
export const serializeErrorSafely = (value: unknown): unknown => {
try {
return serializeError(value);
} catch (serializationError) {
return {
name: "ErrorSerializationFailed",
message: "Failed to serialize API error payload",
originalType: getSerializedValueType(value),
serializationError:
serializationError instanceof Error ? serializationError.message : String(serializationError),
};
}
};
const getSyntheticError = (apiVersion: TApiVersion, correlationId: string): Error => {
if (apiVersion === "unknown") {
return new Error(`API error, id: ${correlationId}`);
}
return new Error(`API ${apiVersion.toUpperCase()} error, id: ${correlationId}`);
};
const getLogMessage = (apiVersion: TApiVersion): string => {
switch (apiVersion) {
case "v1":
return "API V1 Error Details";
case "v2":
return "API V2 Error Details";
case "v3":
return "API V3 Error Details";
default:
return "API Error Details";
}
};
const buildApiErrorContext = ({
request,
status,
apiVersion,
}: {
request: TRequestLike;
status: number;
apiVersion?: TApiVersion;
}): TApiErrorContext => {
const path = getPathname(request.url);
return {
apiVersion: apiVersion ?? getApiVersionFromPath(path),
correlationId: request.headers.get("x-request-id") ?? "",
method: request.method,
path,
status,
};
};
export const buildSentryCaptureContext = ({
context,
errorPayload,
originalErrorPayload,
}: {
context: TApiErrorContext;
errorPayload: unknown;
originalErrorPayload: unknown;
}): TSentryCaptureContext => ({
level: "error",
tags: {
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
},
extra: {
error: errorPayload,
originalError: originalErrorPayload,
},
contexts: {
apiRequest: {
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
status: context.status,
},
},
});
export const emitApiErrorLog = (context: TApiErrorContext, errorPayload?: unknown): void => {
const logContext =
errorPayload === undefined
? context
: {
...context,
error: errorPayload,
};
logger.withContext(logContext).error(getLogMessage(context.apiVersion));
};
export const emitApiErrorToSentry = ({
error,
captureContext,
}: {
error: Error;
captureContext: TSentryCaptureContext;
}): void => {
Sentry.captureException(error, captureContext);
};
const logReporterFailure = (context: TApiErrorContext, reportingError: unknown): void => {
try {
logger.error(
{
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
status: context.status,
reportingError: serializeErrorSafely(reportingError),
},
"Failed to report API error"
);
} catch {
// Swallow reporter failures so API responses are never affected by observability issues.
}
};
export const reportApiError = ({
request,
status,
error,
apiVersion,
originalError,
}: {
request: TRequestLike;
status: number;
error?: unknown;
apiVersion?: TApiVersion;
originalError?: unknown;
}): void => {
const context = buildApiErrorContext({
request,
status,
apiVersion,
});
const capturedError =
error instanceof Error ? error : getSyntheticError(context.apiVersion, context.correlationId);
const logErrorPayload = error === undefined ? undefined : serializeErrorSafely(error);
const errorPayload = serializeErrorSafely(error ?? capturedError);
const originalErrorPayload = serializeErrorSafely(originalError ?? error);
try {
emitApiErrorLog(context, logErrorPayload);
} catch (reportingError) {
logReporterFailure(context, reportingError);
}
if (SENTRY_DSN && IS_PRODUCTION && status >= 500) {
try {
emitApiErrorToSentry({
error: capturedError,
captureContext: buildSentryCaptureContext({
context,
errorPayload,
originalErrorPayload,
}),
});
} catch (reportingError) {
logReporterFailure(context, reportingError);
}
}
};
@@ -0,0 +1,111 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{invalid-json",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
malformedJsonMessage: "Malformed JSON in request body",
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("invalid_json");
expect(result.details).toEqual({
error: expect.any(String),
});
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Malformed JSON in request body",
details: {
error: expect.any(String),
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: "not-boolean",
}),
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("invalid_body");
expect(result.details).toEqual(
expect.objectContaining({
finished: expect.any(String),
})
);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: expect.objectContaining({
finished: expect.any(String),
}),
});
});
test("returns parsed data when JSON parsing and schema validation succeed", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: true,
}),
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
environmentId: z.string(),
}),
buildInput: (jsonInput) => ({
...(jsonInput as Record<string, unknown>),
environmentId: "env_123",
}),
});
expect(result).toEqual({
data: {
environmentId: "env_123",
finished: true,
},
});
});
});
@@ -0,0 +1,71 @@
import { z } from "zod";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
issue: TJsonBodyValidationIssue;
response: Response;
};
type TJsonBodyValidationSuccess<TData> = {
data: TData;
};
export type TParseAndValidateJsonBodyResult<TData> =
| TJsonBodyValidationError
| TJsonBodyValidationSuccess<TData>;
type TParseAndValidateJsonBodyOptions<TSchema extends z.ZodTypeAny> = {
request: Request;
schema: TSchema;
buildInput?: (jsonInput: unknown) => unknown;
malformedJsonMessage?: string;
validationMessage?: string;
};
const DEFAULT_MALFORMED_JSON_MESSAGE = "Malformed JSON input, please check your request body";
const DEFAULT_VALIDATION_MESSAGE = "Fields are missing or incorrectly formatted";
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error occurred";
export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
request,
schema,
buildInput,
malformedJsonMessage = DEFAULT_MALFORMED_JSON_MESSAGE,
validationMessage = DEFAULT_VALIDATION_MESSAGE,
}: TParseAndValidateJsonBodyOptions<TSchema>): Promise<
TParseAndValidateJsonBodyResult<z.output<TSchema>>
> => {
let jsonInput: unknown;
try {
jsonInput = await request.json();
} catch (error) {
const details = { error: getErrorMessage(error) };
return {
details,
issue: "invalid_json",
response: responses.badRequestResponse(malformedJsonMessage, details, true),
};
}
const inputValidation = schema.safeParse(buildInput ? buildInput(jsonInput) : jsonInput);
if (!inputValidation.success) {
const details = transformErrorToDetails(inputValidation.error);
return {
details,
issue: "invalid_body",
response: responses.badRequestResponse(validationMessage, details, true),
};
}
return { data: inputValidation.data };
};
+146 -103
View File
@@ -6,36 +6,20 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator"; import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response"; import { responses } from "./response";
// Mocks
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true, __esModule: true,
queueAuditEvent: vi.fn(), queueAuditEvent: vi.fn(),
})); }));
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
vi.mock("@sentry/nextjs", () => ({ vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(), captureException: vi.fn(),
withScope: vi.fn((callback) => { withScope: vi.fn(),
callback(mockSentryScope);
return mockSentryScope;
}),
})); }));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
const mockContextualLoggerError = vi.fn(); const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn(); const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn(); const mockContextualLoggerInfo = vi.fn();
const V1_MANAGEMENT_SURVEYS_URL = "https://api.test/api/v1/management/surveys";
// Mock Sentry scope that can be referenced in tests
const mockSentryScope = {
setTag: vi.fn(),
setExtra: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
};
vi.mock("@formbricks/logger", () => { vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({ const mockWithContextInstance = vi.fn(() => ({
@@ -76,13 +60,10 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
}); });
vi.mock("@/modules/core/rate-limit/helpers", () => ({ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(), applyRateLimit: vi.fn(),
})); }));
vi.mock("@/modules/core/rate-limit/public-edge-rate-limit", () => ({
applyPublicIpRateLimitForRoute: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: { rateLimitConfigs: {
api: { api: {
@@ -93,7 +74,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
})); }));
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) { function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
// Parse the URL to get the pathname
const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url); const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url);
return { return {
@@ -122,7 +102,6 @@ describe("withV1ApiWrapper", () => {
vi.doMock("@/lib/constants", () => ({ vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
EDGE_RATE_LIMIT_PROVIDER: "none",
IS_PRODUCTION: true, IS_PRODUCTION: true,
SENTRY_DSN: "dsn", SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key", ENCRYPTION_KEY: "test-key",
@@ -130,22 +109,14 @@ describe("withV1ApiWrapper", () => {
})); }));
vi.clearAllMocks(); vi.clearAllMocks();
// Reset mock Sentry scope calls
mockSentryScope.setTag.mockClear();
mockSentryScope.setExtra.mockClear();
mockSentryScope.setContext.mockClear();
mockSentryScope.setLevel.mockClear();
}); });
test("logs and audits on error response with API key authentication", async () => { test("logs and audits on error response with API key authentication", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import( const { queueAuditEvent: mockedQueueAuditEvent } =
"@/modules/ee/audit-logs/lib/handler" (await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -165,7 +136,7 @@ describe("withV1ApiWrapper", () => {
}); });
const req = createMockRequest({ const req = createMockRequest({
url: "https://api.test/v1/management/surveys", url: V1_MANAGEMENT_SURVEYS_URL,
headers: new Map([["x-request-id", "abc-123"]]), headers: new Map([["x-request-id", "abc-123"]]),
}); });
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
@@ -187,19 +158,41 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1", organizationId: "org-1",
}) })
); );
expect(Sentry.withScope).toHaveBeenCalled(); expect(Sentry.withScope).not.toHaveBeenCalled();
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined); expect(Sentry.captureException).toHaveBeenCalledWith(
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); expect.any(Error),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "abc-123",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "API V1 error, id: abc-123",
}),
originalError: undefined,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v1",
correlationId: "abc-123",
method: "GET",
path: "/api/v1/management/surveys",
status: 500,
}),
}),
})
);
}); });
test("does not log Sentry if not 500", async () => { test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import( const { queueAuditEvent: mockedQueueAuditEvent } =
"@/modules/ee/audit-logs/lib/handler" (await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -218,7 +211,7 @@ describe("withV1ApiWrapper", () => {
}; };
}); });
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined); await wrapped(req, undefined);
@@ -241,13 +234,11 @@ describe("withV1ApiWrapper", () => {
}); });
test("logs and audits on thrown error", async () => { test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import( const { queueAuditEvent: mockedQueueAuditEvent } =
"@/modules/ee/audit-logs/lib/handler" (await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -265,7 +256,7 @@ describe("withV1ApiWrapper", () => {
}); });
const req = createMockRequest({ const req = createMockRequest({
url: "https://api.test/v1/management/surveys", url: V1_MANAGEMENT_SURVEYS_URL,
headers: new Map([["x-request-id", "err-1"]]), headers: new Map([["x-request-id", "err-1"]]),
}); });
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
@@ -294,18 +285,86 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1", organizationId: "org-1",
}) })
); );
expect(Sentry.withScope).toHaveBeenCalled(); expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "fail!",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "err-1",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "fail!",
}),
originalError: expect.objectContaining({
message: "fail!",
}),
}),
})
);
});
test("uses handler result error for handled 500 responses", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handledError = new Error("handled failure");
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
error: handledError,
});
const req = createMockRequest({
url: "https://api.test/api/v2/client/environment",
headers: new Map([["x-request-id", "handled-1"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
handledError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "handled-1",
method: "GET",
path: "/api/v2/client/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "handled failure",
}),
originalError: expect.objectContaining({
message: "handled failure",
}),
}),
})
);
}); });
test("does not log on success response but still audits", async () => { test("does not log on success response but still audits", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import( const { queueAuditEvent: mockedQueueAuditEvent } =
"@/modules/ee/audit-logs/lib/handler" (await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -324,7 +383,7 @@ describe("withV1ApiWrapper", () => {
}; };
}); });
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined); await wrapped(req, undefined);
@@ -349,20 +408,17 @@ describe("withV1ApiWrapper", () => {
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => { test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
vi.doMock("@/lib/constants", () => ({ vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false, AUDIT_LOG_ENABLED: false,
EDGE_RATE_LIMIT_PROVIDER: "none",
IS_PRODUCTION: true, IS_PRODUCTION: true,
SENTRY_DSN: "dsn", SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key", ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379", REDIS_URL: "redis://localhost:6379",
})); }));
const { queueAuditEvent: mockedQueueAuditEvent } = (await import( const { queueAuditEvent: mockedQueueAuditEvent } =
"@/modules/ee/audit-logs/lib/handler" (await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -377,7 +433,7 @@ describe("withV1ApiWrapper", () => {
response: responses.internalServerErrorResponse("fail"), response: responses.internalServerErrorResponse("fail"),
}); });
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined); await wrapped(req, undefined);
@@ -385,13 +441,10 @@ describe("withV1ApiWrapper", () => {
}); });
test("handles client-side API routes without authentication", async () => { test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyPublicIpRateLimitForRoute } = await import( const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
"@/modules/core/rate-limit/public-edge-rate-limit"
);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({ vi.mocked(isManagementApiRoute).mockReturnValue({
@@ -400,7 +453,7 @@ describe("withV1ApiWrapper", () => {
}); });
vi.mocked(isIntegrationRoute).mockReturnValue(false); vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null); vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyPublicIpRateLimitForRoute).mockResolvedValue("app"); vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({ const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }), response: responses.successResponse({ data: "test" }),
@@ -418,17 +471,11 @@ describe("withV1ApiWrapper", () => {
auditLog: undefined, auditLog: undefined,
authentication: null, authentication: null,
}); });
expect(applyPublicIpRateLimitForRoute).toHaveBeenCalledWith(
"/api/v1/client/displays",
"GET",
expect.objectContaining({ max: 100 })
);
}); });
test("returns authentication error for non-client routes without auth", async () => { test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -440,7 +487,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(authenticateRequest).mockResolvedValue(null); vi.mocked(authenticateRequest).mockResolvedValue(null);
const handler = vi.fn(); const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler }); const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined); const res = await wrapped(req, undefined);
@@ -450,9 +497,8 @@ describe("withV1ApiWrapper", () => {
}); });
test("uses unauthenticatedResponse when provided instead of default 401", async () => { test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
const { getServerSession } = await import("next-auth"); const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -484,9 +530,8 @@ describe("withV1ApiWrapper", () => {
test("handles rate limiting errors", async () => { test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers"); const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -501,7 +546,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError); vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn(); const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler }); const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined); const res = await wrapped(req, undefined);
@@ -511,13 +556,11 @@ describe("withV1ApiWrapper", () => {
}); });
test("skips audit log creation when no action/targetType provided", async () => { test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import( const { queueAuditEvent: mockedQueueAuditEvent } =
"@/modules/ee/audit-logs/lib/handler" (await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
"@/app/middleware/endpoint-validator" await import("@/app/middleware/endpoint-validator");
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -531,7 +574,7 @@ describe("withV1ApiWrapper", () => {
response: responses.successResponse({ data: "test" }), response: responses.successResponse({ data: "test" }),
}); });
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler }); const wrapped = withV1ApiWrapper({ handler });
await wrapped(req, undefined); await wrapped(req, undefined);
@@ -550,7 +593,7 @@ describe("buildAuditLogBaseObject", () => {
test("creates audit log base object with correct structure", async () => { test("creates audit log base object with correct structure", async () => {
const { buildAuditLogBaseObject } = await import("./with-api-logging"); const { buildAuditLogBaseObject } = await import("./with-api-logging");
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys"); const result = buildAuditLogBaseObject("created", "survey", V1_MANAGEMENT_SURVEYS_URL);
expect(result).toEqual({ expect(result).toEqual({
action: "created", action: "created",
@@ -562,7 +605,7 @@ describe("buildAuditLogBaseObject", () => {
oldObject: undefined, oldObject: undefined,
newObject: undefined, newObject: undefined,
userType: "api", userType: "api",
apiUrl: "https://api.test/v1/management/surveys", apiUrl: V1_MANAGEMENT_SURVEYS_URL,
}); });
}); });
}); });
+22 -54
View File
@@ -1,9 +1,9 @@
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth"; import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { import {
AuthenticationMethod, AuthenticationMethod,
@@ -11,10 +11,9 @@ import {
isIntegrationRoute, isIntegrationRoute,
isManagementApiRoute, isManagementApiRoute,
} from "@/app/middleware/endpoint-validator"; } from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { applyPublicIpRateLimitForRoute } from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit"; import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
@@ -34,7 +33,10 @@ export interface THandlerParams<TProps = unknown> {
} }
// Interface for wrapper function parameters // Interface for wrapper function parameters
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> { export interface TWithV1ApiWrapperParams<
TResult extends { response: Response; error?: unknown },
TProps = unknown,
> {
handler: (params: THandlerParams<TProps>) => Promise<TResult>; handler: (params: THandlerParams<TProps>) => Promise<TResult>;
action?: TAuditAction; action?: TAuditAction;
targetType?: TAuditTarget; targetType?: TAuditTarget;
@@ -55,22 +57,14 @@ enum ApiV1RouteTypeEnum {
/** /**
* Apply client-side API rate limiting (IP-based) * Apply client-side API rate limiting (IP-based)
*/ */
const applyClientRateLimit = async ( const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
req: NextRequest, await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
customRateLimitConfig?: TRateLimitConfig
): Promise<void> => {
await applyPublicIpRateLimitForRoute(
req.nextUrl.pathname,
req.method,
customRateLimitConfig ?? rateLimitConfigs.api.client
);
}; };
/** /**
* Handle rate limiting based on authentication and API type * Handle rate limiting based on authentication and API type
*/ */
const handleRateLimiting = async ( const handleRateLimiting = async (
req: NextRequest,
authentication: TApiV1Authentication, authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum, routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig customRateLimitConfig?: TRateLimitConfig
@@ -90,7 +84,7 @@ const handleRateLimiting = async (
} }
if (routeType === ApiV1RouteTypeEnum.Client) { if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(req, customRateLimitConfig); await applyClientRateLimit(customRateLimitConfig);
} }
} catch (error) { } catch (error) {
return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded"); return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded");
@@ -102,7 +96,7 @@ const handleRateLimiting = async (
/** /**
* Execute handler with error handling * Execute handler with error handling
*/ */
const executeHandler = async <TResult extends { response: Response }, TProps>( const executeHandler = async <TResult extends { response: Response; error?: unknown }, TProps>(
handler: (params: THandlerParams<TProps>) => Promise<TResult>, handler: (params: THandlerParams<TProps>) => Promise<TResult>,
req: NextRequest, req: NextRequest,
props: TProps, props: TProps,
@@ -167,34 +161,12 @@ const handleAuthentication = async (
/** /**
* Log error details to system logger and Sentry * Log error details to system logger and Sentry
*/ */
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => { const logErrorDetails = (res: Response, req: NextRequest, error?: unknown): void => {
const logContext = { reportApiError({
correlationId, request: req,
method: req.method,
path: req.url,
status: res.status, status: res.status,
...(error && { error }), error,
}; });
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
// Set correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
// If we have an actual error, capture it with full stacktrace
// Otherwise, create a generic error with context
if (error instanceof Error) {
Sentry.captureException(error);
} else {
scope.setExtra("originalError", error);
const genericError = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(genericError);
}
});
}
}; };
/** /**
@@ -204,7 +176,7 @@ const processResponse = async (
res: Response, res: Response,
req: NextRequest, req: NextRequest,
auditLog?: TApiAuditLog, auditLog?: TApiAuditLog,
error?: any error?: unknown
): Promise<void> => { ): Promise<void> => {
const correlationId = req.headers.get("x-request-id") ?? ""; const correlationId = req.headers.get("x-request-id") ?? "";
@@ -219,7 +191,7 @@ const processResponse = async (
// Handle error logging // Handle error logging
if (!res.ok) { if (!res.ok) {
logErrorDetails(res, req, correlationId, error); logErrorDetails(res, req, error);
} }
// Queue audit event if enabled and audit log exists // Queue audit event if enabled and audit log exists
@@ -276,7 +248,7 @@ const getRouteType = (
* @returns Wrapped handler function that returns the final HTTP response * @returns Wrapped handler function that returns the final HTTP response
* *
*/ */
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>( export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => { ): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params; const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
@@ -314,21 +286,17 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
// === Rate Limiting === // === Rate Limiting ===
if (isRateLimited) { if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting( const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
req,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse; if (rateLimitResponse) return rateLimitResponse;
} }
// === Handler Execution === // === Handler Execution ===
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication); const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
const res = result.response; const res = result.response;
const reportedError = result.error ?? error;
// === Response Processing & Logging === // === Response Processing & Logging ===
await processResponse(res, req, auditLog, error); await processResponse(res, req, auditLog, reportedError);
return res; return res;
}; };
+1
View File
@@ -4926,6 +4926,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
showLanguageSwitch: false, showLanguageSwitch: false,
followUps: [], followUps: [],
isBackButtonHidden: false, isBackButtonHidden: false,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: false, isCaptureIpEnabled: false,
metadata: {}, metadata: {},
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime) questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
@@ -48,10 +48,6 @@ describe("endpoint-validator", () => {
isClientSideApi: true, isClientSideApi: true,
isRateLimited: false, isRateLimited: false,
}); });
expect(isClientSideApiRoute("/api/v1/client/og-image")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
}); });
test("should return false for non-client-side API routes", () => { test("should return false for non-client-side API routes", () => {
@@ -13,7 +13,7 @@ export enum AuthenticationMethod {
export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => { export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => {
// Open Graph image generation route is a client side API route but it should not be rate limited // Open Graph image generation route is a client side API route but it should not be rate limited
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(url)) return { isClientSideApi: true, isRateLimited: false }; if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
const regex = /^\/api\/v\d+\/client\//; const regex = /^\/api\/v\d+\/client\//;
return { isClientSideApi: regex.test(url), isRateLimited: true }; return { isClientSideApi: regex.test(url), isRateLimited: true };
@@ -7,6 +7,7 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service"; import { gethasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service"; import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service"; import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing"; import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
@@ -49,6 +50,11 @@ export const createOrganizationAction = authenticatedActionClient
ctx.auditLoggingCtx.organizationId = newOrganization.id; ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization; ctx.auditLoggingCtx.newObject = newOrganization;
capturePostHogEvent(ctx.user.id, "organization_created", {
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
});
return newOrganization; return newOrganization;
}) })
); );
+32 -14
View File
@@ -51,6 +51,8 @@ checksums:
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
auth/login/lost_access: 917c4665b99c37377ed522ba53249006 auth/login/lost_access: 917c4665b99c37377ed522ba53249006
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3 auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943 auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
@@ -121,6 +123,8 @@ checksums:
common/bottom_right: aaef9a70ef795affc806c6d1853d8373 common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade common/cancel: 2e2a849c2223911717de8caa2c71bade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507 common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603 common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -188,6 +192,7 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9 common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5 common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22 common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97 common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
@@ -258,6 +263,7 @@ checksums:
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08 common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership: 83c856bbc2af99d8c3d860959d1d2a85 common/membership: 83c856bbc2af99d8c3d860959d1d2a85
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4 common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/meta: 842eac888f134f3525f8ea613d933687
common/metadata: 695d4f7da261ba76e3be4de495491028 common/metadata: 695d4f7da261ba76e3be4de495491028
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72 common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2 common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
@@ -288,6 +294,9 @@ checksums:
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
common/number: 2789f8391f63e7200a5521078aab017d common/number: 2789f8391f63e7200a5521078aab017d
common/off: 7e33a70258401abe652c9f1b595fabda common/off: 7e33a70258401abe652c9f1b595fabda
common/offline_all_responses_synced: 9760250890a3a1901163c3f9e91602cc
common/offline_syncing_responses: 6a518ff0248a29216b60a206e6966d4d
common/offline_you_are_offline: c8fa81ac3e9cad1c64b963ec3f372085
common/on: 1929bcf2fba8003c043b446a851bcb4f common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9 common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709 common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
@@ -299,6 +308,7 @@ checksums:
common/organization_id: ef09b71c84a25b5da02a23c77e68a335 common/organization_id: ef09b71c84a25b5da02a23c77e68a335
common/organization_settings: 11528aa89ae9935e55dcb54478058775 common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2 common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8 common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5 common/overview: 30c54e4dc4ce599b87d94be34a8617f5
@@ -370,7 +380,6 @@ checksums:
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79 common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
common/shown: 63e4ffb245c05e04b636446c3dbdd8df common/shown: 63e4ffb245c05e04b636446c3dbdd8df
common/size: 227fadeeff951e041ff42031a11a4626 common/size: 227fadeeff951e041ff42031a11a4626
common/skip: b7f28dfa2f58b80b149bb82b392d0291
common/skipped: d496f0f667e1b4364b954db71335d4ef common/skipped: d496f0f667e1b4364b954db71335d4ef
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34 common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0 common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
@@ -404,6 +413,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02 common/teams: b63448c05270497973ac4407047dae02
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/text: 4ddccc1974775ed7357f9beaf9361cec common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42 common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -441,7 +451,6 @@ checksums:
common/website_survey: 17513d25a07b6361768a15ec622b021b common/website_survey: 17513d25a07b6361768a15ec622b021b
common/weeks: 545de30df4f44d3f6d1d344af6a10815 common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7 common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387 common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1 common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
@@ -475,7 +484,7 @@ checksums:
emails/forgot_password_email_change_password: fe6d4ba303b82f4833b3293f0c4e88c0 emails/forgot_password_email_change_password: fe6d4ba303b82f4833b3293f0c4e88c0
emails/forgot_password_email_did_not_request: 79d35c3800e23e9d4c95bf33f250104f emails/forgot_password_email_did_not_request: 79d35c3800e23e9d4c95bf33f250104f
emails/forgot_password_email_heading: fe6d4ba303b82f4833b3293f0c4e88c0 emails/forgot_password_email_heading: fe6d4ba303b82f4833b3293f0c4e88c0
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957 emails/forgot_password_email_link_valid_for_24_hours: 962358a7f9674f13f49278afa15d14d3
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0 emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
@@ -1067,6 +1076,17 @@ checksums:
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112 environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43 environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
environments/settings/general/ai_data_analysis_disabled_for_organization: 2066fe71ecf8994ba738c79b63a1934b
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
environments/settings/general/ai_features_not_enabled_for_organization: e344473bd813fc43f69c51138f74bc8e
environments/settings/general/ai_instance_not_configured: 37a80753eb22b5bfc985d0e1f2145e3f
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
environments/settings/general/ai_smart_tools_disabled_for_organization: 13df84ae47d35dfa6e86ffa62f29c75d
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37 environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
@@ -1105,6 +1125,7 @@ checksums:
environments/settings/general/only_org_owner_can_perform_action: 0244ff3c6de787935e592eac4c5e4f0b environments/settings/general/only_org_owner_can_perform_action: 0244ff3c6de787935e592eac4c5e4f0b
environments/settings/general/organization_created_successfully: 1ce874980bdd7d5de8402c276fb97a57 environments/settings/general/organization_created_successfully: 1ce874980bdd7d5de8402c276fb97a57
environments/settings/general/organization_deleted_successfully: e51fd7ee9efda04a373450ea21b242db environments/settings/general/organization_deleted_successfully: e51fd7ee9efda04a373450ea21b242db
environments/settings/general/organization_deletion_disabled: 6fb0e71c218ed4ab3be175f1094feada
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04 environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
@@ -1267,6 +1288,8 @@ checksums:
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516 environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0 environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012 environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
environments/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231 environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681 environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963 environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -1688,6 +1711,11 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17 environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421 environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664 environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/validate_id_duplicate: f88ec35a9bd4921fb096817b9263b59a
environments/surveys/edit/validate_id_empty: 3ee25d429ed5ca9e047f9aee95496323
environments/surveys/edit/validate_id_invalid_chars: 50239938a408c04b02d77b8cd096d767
environments/surveys/edit/validate_id_no_spaces: 11c4408574c11c51f30fe98be18baadb
environments/surveys/edit/validate_id_reserved: 2696af4715ee91539e247b1b9a931721
environments/surveys/edit/validation/add_validation_rule: e0c3208977140e5475df3e9b08927dbf environments/surveys/edit/validation/add_validation_rule: e0c3208977140e5475df3e9b08927dbf
environments/surveys/edit/validation/answer_all_rows: 5ca73b038ac41922a09802fef4b5afc0 environments/surveys/edit/validation/answer_all_rows: 5ca73b038ac41922a09802fef4b5afc0
environments/surveys/edit/validation/characters: e26d6bb531181ec1ed551e264bc86259 environments/surveys/edit/validation/characters: e26d6bb531181ec1ed551e264bc86259
@@ -1998,6 +2026,7 @@ checksums:
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9 environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7 environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
@@ -3179,14 +3208,3 @@ checksums:
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682 templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1 templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2 templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
workflows/placeholder: f5d943582bf25e8734930844e598457b
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
workflows/thank_you_description: 7623c1ba4f059c8d9e68aae3360b20b1
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
+97
View File
@@ -0,0 +1,97 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});
+41 -1
View File
@@ -1,9 +1,13 @@
import { Prisma } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account"; import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
type TAccountDbClient = PrismaClient | Prisma.TransactionClient;
const getDbClient = (tx?: Prisma.TransactionClient): TAccountDbClient => tx ?? prisma;
export const createAccount = async (accountData: TAccountInput): Promise<TAccount> => { export const createAccount = async (accountData: TAccountInput): Promise<TAccount> => {
validateInputs([accountData, ZAccountInput]); validateInputs([accountData, ZAccountInput]);
@@ -20,3 +24,39 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error; throw error;
} }
}; };
export const upsertAccount = async (
accountData: TAccountInput,
tx?: Prisma.TransactionClient
): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await getDbClient(tx).account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
+184
View File
@@ -0,0 +1,184 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getOrganizationAIConfig,
isInstanceAIConfigured,
} from "./service";
const mocks = vi.hoisted(() => ({
generateText: vi.fn(),
isAiConfigured: vi.fn(),
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
getTranslate: vi.fn(),
loggerError: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/ai", () => ({
AIConfigurationError: class AIConfigurationError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
},
generateText: mocks.generateText,
isAiConfigured: mocks.isAiConfigured,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/lib/env", () => ({
env: {
AI_PROVIDER: "gcp",
AI_MODEL: "gemini-2.5-flash",
AI_GCP_PROJECT: "vertex-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_CREDENTIALS_JSON: undefined,
AI_GCP_APPLICATION_CREDENTIALS: "/tmp/vertex.json",
AI_AWS_REGION: "us-east-1",
AI_AWS_ACCESS_KEY_ID: "aws-access-key-id",
AI_AWS_SECRET_ACCESS_KEY: "aws-secret-access-key",
AI_AWS_SESSION_TOKEN: undefined,
AI_AZURE_BASE_URL: "https://example-resource.openai.azure.com/openai",
AI_AZURE_RESOURCE_NAME: undefined,
AI_AZURE_API_KEY: "azure-api-key",
AI_AZURE_API_VERSION: "v1",
},
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: mocks.getOrganization,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: mocks.getTranslate,
}));
describe("AI organization service", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isAiConfigured.mockReturnValue(true);
mocks.getOrganization.mockResolvedValue({
id: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key
);
});
test("returns the instance AI status and organization settings", async () => {
const configured = isInstanceAIConfigured();
const result = await getOrganizationAIConfig("org_1");
expect(configured).toBe(true);
expect(result).toMatchObject({
organizationId: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
isAISmartToolsEntitled: true,
isAIDataAnalysisEntitled: true,
isInstanceConfigured: true,
});
});
test("throws when the organization cannot be found", async () => {
mocks.getOrganization.mockResolvedValueOnce(null);
await expect(getOrganizationAIConfig("org_missing")).rejects.toThrow(ResourceNotFoundError);
});
test("fails closed when the organization is not entitled to AI", async () => {
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
});
test("fails closed when the requested AI capability is disabled", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: "org_1",
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: true,
});
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
});
test("fails closed when the instance AI configuration is incomplete", async () => {
mocks.isAiConfigured.mockReturnValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
});
test("generates organization AI text with the configured package abstraction", async () => {
const generatedText = { text: "Translated text" };
mocks.generateText.mockResolvedValueOnce(generatedText);
const result = await generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
});
expect(result).toBe(generatedText);
expect(mocks.generateText).toHaveBeenCalledWith(
{
prompt: "Translate this survey",
},
expect.objectContaining({
AI_PROVIDER: "gcp",
AI_MODEL: "gemini-2.5-flash",
AI_GCP_PROJECT: "vertex-project",
})
);
});
test("logs and rethrows generation errors", async () => {
const modelError = new Error("provider boom");
mocks.generateText.mockRejectedValueOnce(modelError);
await expect(
generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
})
).rejects.toThrow(modelError);
expect(mocks.loggerError).toHaveBeenCalledWith(
{
organizationId: "org_1",
capability: "smartTools",
isInstanceConfigured: true,
errorCode: undefined,
err: modelError,
},
"Failed to generate organization AI text"
);
});
});
+104
View File
@@ -0,0 +1,104 @@
import "server-only";
import { AIConfigurationError, generateText, isAiConfigured } from "@formbricks/ai";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
isAISmartToolsEntitled: boolean;
isAIDataAnalysisEntitled: boolean;
isInstanceConfigured: boolean;
}
export const isInstanceAIConfigured = (): boolean => isAiConfigured(env);
export const getOrganizationAIConfig = async (organizationId: string): Promise<TOrganizationAIConfig> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
getIsAISmartToolsEnabled(organizationId),
getIsAIDataAnalysisEnabled(organizationId),
]);
return {
organizationId,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
isAISmartToolsEntitled,
isAIDataAnalysisEntitled,
isInstanceConfigured: isInstanceAIConfigured(),
};
};
export const assertOrganizationAIConfigured = async (
organizationId: string,
capability: "smartTools" | "dataAnalysis"
): Promise<TOrganizationAIConfig> => {
const t = await getTranslate();
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!isCapabilityEntitled) {
throw new OperationNotAllowedError(
t("environments.settings.general.ai_features_not_enabled_for_organization")
);
}
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(
t("environments.settings.general.ai_smart_tools_disabled_for_organization")
);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(
t("environments.settings.general.ai_data_analysis_disabled_for_organization")
);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
}
return aiConfig;
};
type TGenerateOrganizationAITextInput = {
organizationId: string;
capability: "smartTools" | "dataAnalysis";
} & Parameters<typeof generateText>[0];
export const generateOrganizationAIText = async ({
organizationId,
capability,
...options
}: TGenerateOrganizationAITextInput): Promise<Awaited<ReturnType<typeof generateText>>> => {
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
try {
return await generateText(options, env);
} catch (error) {
logger.error(
{
organizationId,
capability,
isInstanceConfigured: aiConfig.isInstanceConfigured,
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
err: error,
},
"Failed to generate organization AI text"
);
throw error;
}
};
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "vitest";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "./utils";
describe("getOrganizationAIEnablementState", () => {
test("blocks enabling when instance AI is not configured", () => {
expect(
getOrganizationAIEnablementState({
isInstanceConfigured: false,
})
).toMatchObject({
canEnableFeatures: false,
blockReason: "instanceNotConfigured",
});
});
test("allows enabling when instance AI is configured", () => {
expect(
getOrganizationAIEnablementState({
isInstanceConfigured: true,
})
).toMatchObject({
canEnableFeatures: true,
});
});
});
describe("getDisplayedOrganizationAISettingValue", () => {
test("renders enabled settings as off when instance AI is not configured", () => {
expect(
getDisplayedOrganizationAISettingValue({
currentValue: true,
isInstanceConfigured: false,
})
).toBe(false);
});
test("renders the stored setting value when instance AI is configured", () => {
expect(
getDisplayedOrganizationAISettingValue({
currentValue: true,
isInstanceConfigured: true,
})
).toBe(true);
});
test("renders false when the stored setting is false and instance AI is configured", () => {
expect(
getDisplayedOrganizationAISettingValue({
currentValue: false,
isInstanceConfigured: true,
})
).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
export type TAIEnablementBlockReason = "instanceNotConfigured";
interface TOrganizationAIEnablementState {
canEnableFeatures: boolean;
blockReason?: TAIEnablementBlockReason;
}
export const getDisplayedOrganizationAISettingValue = ({
currentValue,
isInstanceConfigured,
}: {
currentValue: boolean;
isInstanceConfigured: boolean;
}): boolean => isInstanceConfigured && currentValue;
export const getOrganizationAIEnablementState = ({
isInstanceConfigured,
}: {
isInstanceConfigured: boolean;
}): TOrganizationAIEnablementState => {
if (!isInstanceConfigured) {
return {
canEnableFeatures: false,
blockReason: "instanceNotConfigured",
};
}
return {
canEnableFeatures: true,
};
};
+4 -1
View File
@@ -3,7 +3,6 @@ import { TUserLocale } from "@formbricks/types/user";
import { env } from "./env"; import { env } from "./env";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1"; export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const EDGE_RATE_LIMIT_PROVIDER = env.EDGE_RATE_LIMIT_PROVIDER ?? "none";
export const IS_PRODUCTION = env.NODE_ENV === "production"; export const IS_PRODUCTION = env.NODE_ENV === "production";
@@ -27,7 +26,10 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL; export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS; export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1"; export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_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); export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
@@ -153,6 +155,7 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const REDIS_URL = env.REDIS_URL; export const REDIS_URL = env.REDIS_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1"; export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
export const BREVO_API_KEY = env.BREVO_API_KEY; export const BREVO_API_KEY = env.BREVO_API_KEY;
export const BREVO_LIST_ID = env.BREVO_LIST_ID; export const BREVO_LIST_ID = env.BREVO_LIST_ID;
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const ORIGINAL_ENV = process.env;
const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
process.env = {
...ORIGINAL_ENV,
NODE_ENV: "test",
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
...overrides,
};
};
describe("env", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
process.env = ORIGINAL_ENV;
});
test("uses the default password reset token lifetime when env var is not set", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
});
test("uses the configured password reset token lifetime", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "45",
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(45);
});
test("fails to load when the password reset token lifetime is not an integer", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "30minutes",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the password reset token lifetime is out of range", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "121",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("allows enabling DEBUG_SHOW_RESET_LINK", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "1",
});
const { env } = await import("./env");
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
});
test("fails to load when DEBUG_SHOW_RESET_LINK is invalid", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "true",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
});
+148 -4
View File
@@ -1,12 +1,120 @@
import { createEnv } from "@t3-oss/env-nextjs"; import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { AI_PROVIDERS } from "@formbricks/types/ai";
export const env = createEnv({ const ZActiveAIProvider = z.enum(AI_PROVIDERS);
const ZAIConfigurationEnv = z.object({
AI_PROVIDER: ZActiveAIProvider.optional(),
AI_MODEL: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
AI_GCP_LOCATION: z.string().optional(),
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
AI_AWS_REGION: z.string().optional(),
AI_AWS_ACCESS_KEY_ID: z.string().optional(),
AI_AWS_SECRET_ACCESS_KEY: z.string().optional(),
AI_AZURE_API_KEY: z.string().optional(),
AI_AZURE_BASE_URL: z.url().optional(),
AI_AZURE_RESOURCE_NAME: z.string().optional(),
});
type TAIConfigurationEnv = z.infer<typeof ZAIConfigurationEnv>;
const addEnvIssue = (ctx: z.RefinementCtx, path: keyof TAIConfigurationEnv, message: string): void => {
ctx.addIssue({
code: "custom",
path: [path],
message,
});
};
const validateActiveAIModel = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
if (values.AI_PROVIDER && !values.AI_MODEL) {
addEnvIssue(ctx, "AI_MODEL", "AI_MODEL is required when AI_PROVIDER is set");
}
};
const validateAwsAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
if (!values.AI_AWS_REGION) {
addEnvIssue(ctx, "AI_AWS_REGION", "AI_AWS_REGION is required when AI_PROVIDER=aws");
}
if (!values.AI_AWS_ACCESS_KEY_ID) {
addEnvIssue(ctx, "AI_AWS_ACCESS_KEY_ID", "AI_AWS_ACCESS_KEY_ID is required when AI_PROVIDER=aws");
}
if (!values.AI_AWS_SECRET_ACCESS_KEY) {
addEnvIssue(ctx, "AI_AWS_SECRET_ACCESS_KEY", "AI_AWS_SECRET_ACCESS_KEY is required when AI_PROVIDER=aws");
}
};
const validateGcpAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
if (!values.AI_GCP_PROJECT) {
addEnvIssue(ctx, "AI_GCP_PROJECT", "AI_GCP_PROJECT is required when AI_PROVIDER=gcp");
}
if (!values.AI_GCP_LOCATION) {
addEnvIssue(ctx, "AI_GCP_LOCATION", "AI_GCP_LOCATION is required when AI_PROVIDER=gcp");
}
if (!values.AI_GCP_CREDENTIALS_JSON && !values.AI_GCP_APPLICATION_CREDENTIALS) {
addEnvIssue(
ctx,
"AI_GCP_CREDENTIALS_JSON",
"AI_GCP_CREDENTIALS_JSON or AI_GCP_APPLICATION_CREDENTIALS is required when AI_PROVIDER=gcp"
);
}
if (values.AI_GCP_CREDENTIALS_JSON) {
try {
JSON.parse(values.AI_GCP_CREDENTIALS_JSON);
} catch {
addEnvIssue(ctx, "AI_GCP_CREDENTIALS_JSON", "AI_GCP_CREDENTIALS_JSON must be valid JSON");
}
}
};
const validateAzureAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
if (!values.AI_AZURE_API_KEY) {
addEnvIssue(ctx, "AI_AZURE_API_KEY", "AI_AZURE_API_KEY is required when AI_PROVIDER=azure");
}
if (!values.AI_AZURE_BASE_URL && !values.AI_AZURE_RESOURCE_NAME) {
addEnvIssue(
ctx,
"AI_AZURE_BASE_URL",
"AI_AZURE_BASE_URL or AI_AZURE_RESOURCE_NAME is required when AI_PROVIDER=azure"
);
}
};
const validateActiveAIProviderConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
validateActiveAIModel(values, ctx);
if (!values.AI_PROVIDER) {
return;
}
const providerValidators: Record<
z.infer<typeof ZActiveAIProvider>,
(values: TAIConfigurationEnv, ctx: z.RefinementCtx) => void
> = {
aws: validateAwsAIConfiguration,
gcp: validateGcpAIConfiguration,
azure: validateAzureAIConfiguration,
};
providerValidators[values.AI_PROVIDER](values, ctx);
};
const parsedEnv = createEnv({
/* /*
* Serverside Environment variables, not available on the client. * Serverside Environment variables, not available on the client.
* Will throw if you access these variables on the client. * Will throw if you access these variables on the client.
*/ */
server: { server: {
AI_PROVIDER: ZActiveAIProvider.optional(),
AI_MODEL: z.string().optional(),
AIRTABLE_CLIENT_ID: z.string().optional(), AIRTABLE_CLIENT_ID: z.string().optional(),
AZUREAD_CLIENT_ID: z.string().optional(), AZUREAD_CLIENT_ID: z.string().optional(),
AZUREAD_CLIENT_SECRET: z.string().optional(), AZUREAD_CLIENT_SECRET: z.string().optional(),
@@ -15,13 +123,14 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(), BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(), BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(), DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(), DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(), AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(), AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(), E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(), EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(), EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
EDGE_RATE_LIMIT_PROVIDER: z.enum(["none", "cloudflare", "cloudarmor", "envoy"]).optional(),
ENCRYPTION_KEY: z.string(), ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(), ENTERPRISE_LICENSE_KEY: z.string().optional(),
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"), ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
@@ -29,9 +138,21 @@ export const env = createEnv({
GITHUB_SECRET: z.string().optional(), GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
AI_GCP_LOCATION: z.string().optional(),
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(), GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(), GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(), GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
AI_AWS_REGION: z.string().optional(),
AI_AWS_ACCESS_KEY_ID: z.string().optional(),
AI_AWS_SECRET_ACCESS_KEY: z.string().optional(),
AI_AWS_SESSION_TOKEN: z.string().optional(),
AI_AZURE_BASE_URL: z.url().optional(),
AI_AZURE_API_KEY: z.string().optional(),
AI_AZURE_API_VERSION: z.string().optional(),
AI_AZURE_RESOURCE_NAME: z.string().optional(),
HTTP_PROXY: z.url().optional(), HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.url().optional(), HTTPS_PROXY: z.url().optional(),
IMPRINT_URL: z IMPRINT_URL: z
@@ -61,11 +182,13 @@ export const env = createEnv({
? z.string().optional() ? z.string().optional()
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"), : z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(), PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().int().min(5).max(120).optional().default(30),
PRIVACY_URL: z PRIVACY_URL: z
.url() .url()
.optional() .optional()
.or(z.string().refine((str) => str === "")), .or(z.string().refine((str) => str === "")),
RATE_LIMITING_DISABLED: z.enum(["1", "0"]).optional(), RATE_LIMITING_DISABLED: z.enum(["1", "0"]).optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
S3_ACCESS_KEY: z.string().optional(), S3_ACCESS_KEY: z.string().optional(),
S3_BUCKET_NAME: z.string().optional(), S3_BUCKET_NAME: z.string().optional(),
S3_REGION: z.string().optional(), S3_REGION: z.string().optional(),
@@ -122,7 +245,7 @@ export const env = createEnv({
AUDIT_LOG_GET_USER_IP: z.enum(["1", "0"]).optional(), AUDIT_LOG_GET_USER_IP: z.enum(["1", "0"]).optional(),
SESSION_MAX_AGE: z SESSION_MAX_AGE: z
.string() .string()
.transform((val) => parseInt(val)) .transform((val) => Number.parseInt(val, 10))
.optional(), .optional(),
SENTRY_ENVIRONMENT: z.string().optional(), SENTRY_ENVIRONMENT: z.string().optional(),
}, },
@@ -134,6 +257,8 @@ export const env = createEnv({
* 💡 You'll get type errors if not all variables from `server` & `client` are included here. * 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/ */
runtimeEnv: { runtimeEnv: {
AI_PROVIDER: process.env.AI_PROVIDER,
AI_MODEL: process.env.AI_MODEL,
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID, AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID, AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET, AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
@@ -142,13 +267,14 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID, BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET, CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG, DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID, AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO, AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
E2E_TESTING: process.env.E2E_TESTING, E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED, EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED, EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
EDGE_RATE_LIMIT_PROVIDER: process.env.EDGE_RATE_LIMIT_PROVIDER,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY, ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
ENVIRONMENT: process.env.ENVIRONMENT, ENVIRONMENT: process.env.ENVIRONMENT,
@@ -156,9 +282,21 @@ export const env = createEnv({
GITHUB_SECRET: process.env.GITHUB_SECRET, GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
AI_GCP_LOCATION: process.env.AI_GCP_LOCATION,
AI_GCP_CREDENTIALS_JSON: process.env.AI_GCP_CREDENTIALS_JSON,
AI_GCP_APPLICATION_CREDENTIALS: process.env.AI_GCP_APPLICATION_CREDENTIALS,
GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET, GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL, GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
AI_AWS_REGION: process.env.AI_AWS_REGION,
AI_AWS_ACCESS_KEY_ID: process.env.AI_AWS_ACCESS_KEY_ID,
AI_AWS_SECRET_ACCESS_KEY: process.env.AI_AWS_SECRET_ACCESS_KEY,
AI_AWS_SESSION_TOKEN: process.env.AI_AWS_SESSION_TOKEN,
AI_AZURE_BASE_URL: process.env.AI_AZURE_BASE_URL,
AI_AZURE_API_KEY: process.env.AI_AZURE_API_KEY,
AI_AZURE_API_VERSION: process.env.AI_AZURE_API_VERSION,
AI_AZURE_RESOURCE_NAME: process.env.AI_AZURE_RESOURCE_NAME,
HTTP_PROXY: process.env.HTTP_PROXY, HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY, HTTPS_PROXY: process.env.HTTPS_PROXY,
IMPRINT_URL: process.env.IMPRINT_URL, IMPRINT_URL: process.env.IMPRINT_URL,
@@ -183,8 +321,10 @@ export const env = createEnv({
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM, OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL, REDIS_URL: process.env.REDIS_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED, PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
PRIVACY_URL: process.env.PRIVACY_URL, PRIVACY_URL: process.env.PRIVACY_URL,
RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED, RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY, S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
S3_REGION: process.env.S3_REGION, S3_REGION: process.env.S3_REGION,
@@ -223,3 +363,7 @@ export const env = createEnv({
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT, SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
}, },
}); });
export const env = ZAIConfigurationEnv.superRefine(validateActiveAIProviderConfiguration)
.transform(() => parsedEnv)
.parse(parsedEnv);
+3 -1
View File
@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => { export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default"; if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode); const language = surveyLanguages.find(
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
);
return language?.default ? "default" : language?.language.code || "default"; return language?.default ? "default" : language?.language.code || "default";
}; };
@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { getBillingFallbackPath } from "./navigation";
describe("getBillingFallbackPath", () => {
test("returns billing settings path for cloud", () => {
const path = getBillingFallbackPath("env_123", true);
expect(path).toBe("/environments/env_123/settings/billing");
});
test("returns enterprise settings path for self-hosted", () => {
const path = getBillingFallbackPath("env_123", false);
expect(path).toBe("/environments/env_123/settings/enterprise");
});
});
+4
View File
@@ -0,0 +1,4 @@
export const getBillingFallbackPath = (environmentId: string, isFormbricksCloud: boolean): string => {
const settingsPath = isFormbricksCloud ? "billing" : "enterprise";
return `/environments/${environmentId}/settings/${settingsPath}`;
};
+27
View File
@@ -68,6 +68,33 @@ describe("Membership Service", () => {
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError); await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError);
}); });
test("uses the transaction client directly when provided", async () => {
const mockMembership: TMembership = {
organizationId: mockOrgId,
userId: mockUserId,
accepted: true,
role: "owner",
};
const tx = {
membership: {
findUnique: vi.fn().mockResolvedValue(mockMembership),
},
} as any;
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId, tx);
expect(result).toEqual(mockMembership);
expect(tx.membership.findUnique).toHaveBeenCalledWith({
where: {
userId_organizationId: {
userId: mockUserId,
organizationId: mockOrgId,
},
},
});
expect(prisma.membership.findUnique).not.toHaveBeenCalled();
});
}); });
describe("createMembership", () => { describe("createMembership", () => {
+49 -25
View File
@@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { Prisma } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
@@ -8,43 +8,67 @@ import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships"; import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
export const getMembershipByUserIdOrganizationId = reactCache( type TMembershipDbClient = PrismaClient | Prisma.TransactionClient;
async (userId: string, organizationId: string): Promise<TMembership | null> => {
validateInputs([userId, ZString], [organizationId, ZString]);
try { const getDbClient = (tx?: Prisma.TransactionClient): TMembershipDbClient => tx ?? prisma;
const membership = await prisma.membership.findUnique({
where: { const getMembershipByUserIdOrganizationIdUncached = async (
userId_organizationId: { userId: string,
userId, organizationId: string,
organizationId, tx?: Prisma.TransactionClient
}, ): Promise<TMembership | null> => {
validateInputs([userId, ZString], [organizationId, ZString]);
try {
const membership = await getDbClient(tx).membership.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
}, },
}); },
});
if (!membership) return null; if (!membership) return null;
return membership; return membership;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting membership by user id and organization id"); logger.error(error, "Error getting membership by user id and organization id");
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
}
throw new UnknownError("Error while fetching membership");
} }
throw new UnknownError("Error while fetching membership");
} }
};
const getMembershipByUserIdOrganizationIdCached = reactCache(async (userId: string, organizationId: string) =>
getMembershipByUserIdOrganizationIdUncached(userId, organizationId)
); );
export const getMembershipByUserIdOrganizationId = async (
userId: string,
organizationId: string,
tx?: Prisma.TransactionClient
): Promise<TMembership | null> => {
if (tx) {
return getMembershipByUserIdOrganizationIdUncached(userId, organizationId, tx);
}
return getMembershipByUserIdOrganizationIdCached(userId, organizationId);
};
export const createMembership = async ( export const createMembership = async (
organizationId: string, organizationId: string,
userId: string, userId: string,
data: Partial<TMembership> data: Partial<TMembership>,
tx?: Prisma.TransactionClient
): Promise<TMembership> => { ): Promise<TMembership> => {
validateInputs([organizationId, ZString], [userId, ZString], [data, ZMembership.partial()]); validateInputs([organizationId, ZString], [userId, ZString], [data, ZMembership.partial()]);
try { try {
const existingMembership = await prisma.membership.findUnique({ const prismaClient = getDbClient(tx);
const existingMembership = await prismaClient.membership.findUnique({
where: { where: {
userId_organizationId: { userId_organizationId: {
userId, userId,
@@ -59,7 +83,7 @@ export const createMembership = async (
let membership: TMembership; let membership: TMembership;
if (!existingMembership) { if (!existingMembership) {
membership = await prisma.membership.create({ membership = await prismaClient.membership.create({
data: { data: {
userId, userId,
organizationId, organizationId,
@@ -68,7 +92,7 @@ export const createMembership = async (
}, },
}); });
} else { } else {
membership = await prisma.membership.update({ membership = await prismaClient.membership.update({
where: { where: {
userId_organizationId: { userId_organizationId: {
userId, userId,
+2 -1
View File
@@ -37,7 +37,8 @@ describe("auth", () => {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
}, },
]; ];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations); vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+10 -5
View File
@@ -72,7 +72,8 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}; };
@@ -124,7 +125,8 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}, },
]; ];
@@ -176,7 +178,8 @@ describe("Organization Service", () => {
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
billing: expectedBilling, billing: expectedBilling,
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}; };
@@ -235,7 +238,8 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }], memberships: [{ userId: "user1" }, { userId: "user2" }],
projects: [ projects: [
@@ -276,7 +280,8 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: expect.any(Date), usageCycleAnchor: expect.any(Date),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}); });
expect(prisma.organization.update).toHaveBeenCalledWith({ expect(prisma.organization.update).toHaveBeenCalledWith({
+30 -29
View File
@@ -34,7 +34,8 @@ export const select = {
stripe: true, stripe: true,
}, },
}, },
isAIEnabled: true, isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true, whitelabel: true,
} satisfies Prisma.OrganizationSelect; } satisfies Prisma.OrganizationSelect;
@@ -72,7 +73,8 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
updatedAt: organization.updatedAt, updatedAt: organization.updatedAt,
name: organization.name, name: organization.name,
billing: mapOrganizationBilling(organization.billing), billing: mapOrganizationBilling(organization.billing),
isAIEnabled: organization.isAIEnabled, isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"], whitelabel: organization.whitelabel as TOrganization["whitelabel"],
}); });
@@ -365,35 +367,34 @@ export const subscribeOrganizationMembersToSurveyResponses = async (
createdBy: string, createdBy: string,
organizationId: string organizationId: string
): Promise<void> => { ): Promise<void> => {
try { const surveyCreator = await prisma.user.findUnique({
const surveyCreator = await prisma.user.findUnique({ where: {
where: { id: createdBy,
id: createdBy, },
}, });
});
if (!surveyCreator) { if (!surveyCreator) {
throw new ResourceNotFoundError("User", createdBy); throw new ResourceNotFoundError("User", createdBy);
}
if (surveyCreator.notificationSettings?.unsubscribedOrganizationIds?.includes(organizationId)) {
return;
}
const defaultSettings = { alert: {} };
const updatedNotificationSettings: TUserNotificationSettings = {
...defaultSettings,
...surveyCreator.notificationSettings,
};
updatedNotificationSettings.alert[surveyId] = true;
await updateUser(surveyCreator.id, {
notificationSettings: updatedNotificationSettings,
});
} catch (error) {
throw error;
} }
if (surveyCreator.notificationSettings?.unsubscribedOrganizationIds?.includes(organizationId)) {
return;
}
const defaultSettings = { alert: {} as NonNullable<TUserNotificationSettings["alert"]> };
const updatedNotificationSettings: TUserNotificationSettings = {
...defaultSettings,
...surveyCreator.notificationSettings,
alert: surveyCreator.notificationSettings?.alert
? { ...surveyCreator.notificationSettings.alert }
: defaultSettings.alert,
};
updatedNotificationSettings.alert[surveyId] = true;
await updateUser(surveyCreator.id, {
notificationSettings: updatedNotificationSettings,
});
}; };
export const getOrganizationsWhereUserIsSingleOwner = reactCache( export const getOrganizationsWhereUserIsSingleOwner = reactCache(
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { capturePostHogEvent } from "./capture";
const mocks = vi.hoisted(() => ({
capture: vi.fn(),
loggerWarn: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.mock("./server", () => ({
posthogServerClient: { capture: mocks.capture },
}));
describe("capturePostHogEvent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("calls posthog capture with correct params", () => {
capturePostHogEvent("user123", "test_event", { key: "value" });
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
key: "value",
$lib: "posthog-node",
source: "server",
},
});
});
test("adds default properties when no properties provided", () => {
capturePostHogEvent("user123", "test_event");
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
$lib: "posthog-node",
source: "server",
},
});
});
test("does not throw when capture throws", () => {
mocks.capture.mockImplementation(() => {
throw new Error("Network error");
});
expect(() => capturePostHogEvent("user123", "test_event")).not.toThrow();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), eventName: "test_event" },
"Failed to capture PostHog event"
);
});
});
describe("capturePostHogEvent with null client", () => {
test("no-ops when posthogServerClient is null", async () => {
vi.clearAllMocks();
vi.resetModules();
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("./server", () => ({
posthogServerClient: null,
}));
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
captureWithNullClient("user123", "test_event", { key: "value" });
expect(mocks.capture).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
});
+27
View File
@@ -0,0 +1,27 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { posthogServerClient } from "./server";
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
export function capturePostHogEvent(
distinctId: string,
eventName: string,
properties?: PostHogEventProperties
): void {
if (!posthogServerClient) return;
try {
posthogServerClient.capture({
distinctId,
event: eventName,
properties: {
...properties,
$lib: "posthog-node",
source: "server",
},
});
} catch (error) {
logger.warn({ error, eventName }, "Failed to capture PostHog event");
}
}
+1
View File
@@ -0,0 +1 @@
export { capturePostHogEvent } from "./capture";
+171
View File
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
describe("server - posthogServerClient", () => {
const g = globalThis as Record<string, unknown>;
const setupMocks = (opts: {
posthogKey?: string;
shutdown?: ReturnType<typeof vi.fn>;
loggerError?: ReturnType<typeof vi.fn>;
}) => {
const shutdown = opts.shutdown ?? vi.fn().mockResolvedValue(undefined);
const loggerError = opts.loggerError ?? vi.fn();
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({ logger: { error: loggerError } }));
vi.doMock("posthog-node", () => ({
PostHog: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
this.capture = vi.fn();
this.shutdown = shutdown;
}),
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: opts.posthogKey }));
return { shutdown, loggerError };
};
beforeEach(() => {
vi.resetModules();
delete g.posthogServerClient;
delete g.posthogHandlersRegistered;
});
test("returns null when POSTHOG_KEY is not set", async () => {
setupMocks({ posthogKey: undefined });
const { posthogServerClient } = await import("./server");
expect(posthogServerClient).toBeNull();
});
test("creates PostHog client when POSTHOG_KEY is set", async () => {
setupMocks({ posthogKey: "phc_test_key" });
const { posthogServerClient } = await import("./server");
expect(posthogServerClient).not.toBeNull();
const { PostHog } = await import("posthog-node");
expect(PostHog).toHaveBeenCalledWith("phc_test_key", {
host: "https://eu.i.posthog.com",
flushAt: 1,
flushInterval: 0,
});
});
test("reuses client from globalThis in development", async () => {
const fakeClient = { capture: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined) };
g.posthogServerClient = fakeClient;
setupMocks({ posthogKey: "phc_test_key" });
const { posthogServerClient } = await import("./server");
expect(posthogServerClient).toBe(fakeClient);
const { PostHog } = await import("posthog-node");
expect(PostHog).not.toHaveBeenCalled();
});
test("caches client on globalThis in non-production", async () => {
vi.stubEnv("NODE_ENV", "development");
setupMocks({ posthogKey: "phc_test_key" });
const { posthogServerClient } = await import("./server");
expect(g.posthogServerClient).toBe(posthogServerClient);
vi.unstubAllEnvs();
});
test("registers signal handlers once when NEXT_RUNTIME is nodejs", async () => {
vi.stubEnv("NEXT_RUNTIME", "nodejs");
setupMocks({ posthogKey: "phc_test_key" });
const processOnSpy = vi.spyOn(process, "on");
await import("./server");
expect(processOnSpy).toHaveBeenCalledWith("SIGTERM", expect.any(Function));
expect(processOnSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function));
expect(g.posthogHandlersRegistered).toBe(true);
processOnSpy.mockRestore();
vi.unstubAllEnvs();
});
test("does not register signal handlers when already registered", async () => {
vi.stubEnv("NEXT_RUNTIME", "nodejs");
g.posthogHandlersRegistered = true;
setupMocks({ posthogKey: "phc_test_key" });
const processOnSpy = vi.spyOn(process, "on");
await import("./server");
const sigCalls = processOnSpy.mock.calls.filter(([event]) => event === "SIGTERM" || event === "SIGINT");
expect(sigCalls).toHaveLength(0);
processOnSpy.mockRestore();
vi.unstubAllEnvs();
});
test("does not register signal handlers when NEXT_RUNTIME is not nodejs", async () => {
vi.stubEnv("NEXT_RUNTIME", "");
setupMocks({ posthogKey: "phc_test_key" });
const processOnSpy = vi.spyOn(process, "on");
await import("./server");
const sigCalls = processOnSpy.mock.calls.filter(([event]) => event === "SIGTERM" || event === "SIGINT");
expect(sigCalls).toHaveLength(0);
processOnSpy.mockRestore();
vi.unstubAllEnvs();
});
test("shutdown handler calls shutdown()", async () => {
vi.stubEnv("NEXT_RUNTIME", "nodejs");
const { shutdown } = setupMocks({ posthogKey: "phc_test_key" });
let sigTermHandler: (() => void) | undefined;
const processOnSpy = vi.spyOn(process, "on").mockImplementation((event, handler) => {
if (event === "SIGTERM") sigTermHandler = handler as () => void;
return process;
});
await import("./server");
expect(sigTermHandler).toBeDefined();
sigTermHandler!();
expect(shutdown).toHaveBeenCalled();
processOnSpy.mockRestore();
vi.unstubAllEnvs();
});
test("shutdown handler logs error if shutdown rejects", async () => {
vi.stubEnv("NEXT_RUNTIME", "nodejs");
const shutdownError = new Error("shutdown failed");
const { loggerError } = setupMocks({
posthogKey: "phc_test_key",
shutdown: vi.fn().mockRejectedValue(shutdownError),
});
let sigTermHandler: (() => void) | undefined;
const processOnSpy = vi.spyOn(process, "on").mockImplementation((event, handler) => {
if (event === "SIGTERM") sigTermHandler = handler as () => void;
return process;
});
await import("./server");
sigTermHandler!();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(loggerError).toHaveBeenCalledWith(shutdownError, "Error shutting down PostHog server client");
processOnSpy.mockRestore();
vi.unstubAllEnvs();
});
});
+43
View File
@@ -0,0 +1,43 @@
import "server-only";
import { PostHog } from "posthog-node";
import { logger } from "@formbricks/logger";
import { POSTHOG_KEY } from "@/lib/constants";
const POSTHOG_HOST = "https://eu.i.posthog.com";
const globalForPostHog = globalThis as unknown as {
posthogServerClient: PostHog | undefined;
posthogHandlersRegistered: boolean | undefined;
};
function createPostHogClient(): PostHog | null {
if (!POSTHOG_KEY) return null;
return new PostHog(POSTHOG_KEY, {
host: POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
});
}
export const posthogServerClient: PostHog | null =
globalForPostHog.posthogServerClient ?? createPostHogClient();
if (process.env.NODE_ENV !== "production" && posthogServerClient) {
globalForPostHog.posthogServerClient = posthogServerClient;
}
if (
process.env.NEXT_RUNTIME === "nodejs" &&
posthogServerClient &&
!globalForPostHog.posthogHandlersRegistered
) {
const shutdownPostHog = () => {
posthogServerClient?.shutdown().catch((err) => {
logger.error(err, "Error shutting down PostHog server client");
});
};
process.on("SIGTERM", shutdownPostHog);
process.on("SIGINT", shutdownPostHog);
globalForPostHog.posthogHandlersRegistered = true;
}
+5 -1
View File
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
if (Array.isArray(responseValue)) { if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels // Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null); // Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
return responseValue
.filter((v) => v !== "")
.map(findChoiceByLabel)
.filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") { } else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label // Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue); const choiceId = findChoiceByLabel(responseValue);
+3 -1
View File
@@ -209,6 +209,7 @@ const baseSurveyProperties = {
}, },
], ],
isBackButtonHidden: false, isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false, isCaptureIpEnabled: false,
endings: [ endings: [
{ {
@@ -232,7 +233,8 @@ export const mockOrganizationOutput: TOrganization = {
name: "mock Organization", name: "mock Organization",
createdAt: currentDate, createdAt: currentDate,
updatedAt: currentDate, updatedAt: currentDate,
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: { billing: {
stripeCustomerId: null, stripeCustomerId: null,
limits: { limits: {
+1
View File
@@ -48,6 +48,7 @@ export const selectSurvey = {
isVerifyEmailEnabled: true, isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true, isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true, isBackButtonHidden: true,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: true, isCaptureIpEnabled: true,
redirectUrl: true, redirectUrl: true,
projectOverwrites: true, projectOverwrites: true,
+4 -2
View File
@@ -70,7 +70,8 @@ describe("User Service", () => {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
}, },
{ {
id: "org2", id: "org2",
@@ -87,7 +88,8 @@ describe("User Service", () => {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAIEnabled: false, isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
}, },
]; ];

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