mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 11:29:22 -05:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d68d1f524 | |||
| ed0c36a87f | |||
| c0ed93be7a | |||
| 439dd0b44e | |||
| 2556f5e15d | |||
| cc0eec3bf0 | |||
| 4b009a8eb4 | |||
| 2aaddf7306 | |||
| fb5d6145d0 | |||
| 59310bac93 | |||
| 322f0be197 | |||
| 1a02f91afd | |||
| cc22ccb22d | |||
| 12763f0ef6 | |||
| d39e3ee638 | |||
| d85242a86b | |||
| ef53065abc | |||
| 805c1c6874 | |||
| 01687e8907 | |||
| 31d455002d | |||
| d96304d86d | |||
| 1064f68435 | |||
| 3d16e859c6 | |||
| af198c5632 | |||
| a43ed2b25c | |||
| 87bcad2b20 | |||
| b5eaa4c7fd | |||
| 995c03bc01 | |||
| b4395a48c5 | |||
| 461e3893fe | |||
| 735a9f84ec | |||
| 8cb8d734cf | |||
| 44d5530b48 | |||
| a314eb391e | |||
| 6c34c316d0 | |||
| 4f26278f16 | |||
| b975e7fa2e | |||
| 6c3052f9e4 | |||
| 5bb8119ebf | |||
| 02411277d4 | |||
| 4cfb8c6d7b | |||
| e74a51a5ff | |||
| 29cc6a10fe | |||
| 01f765e969 | |||
| 9366960f18 | |||
| 697dc9cc99 | |||
| 83bc272ed2 | |||
| 59cc9c564e | |||
| 20dc147682 | |||
| 2bb7a6f277 | |||
| deb062dd03 | |||
| 474be86d33 |
@@ -0,0 +1,9 @@
|
|||||||
|
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||||
|
version = 1
|
||||||
|
name = "formbricks"
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
script = '''
|
||||||
|
pnpm install
|
||||||
|
pnpm dev:setup
|
||||||
|
'''
|
||||||
+38
-3
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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.
|
|
||||||
|
|
||||||
[](https://railway.app/new/template/PPDzCd)
|
|
||||||
|
|
||||||
##### RepoCloud
|
|
||||||
|
|
||||||
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
|
||||||
|
|
||||||
[](https://repocloud.io/details/?app_id=254)
|
|
||||||
|
|
||||||
##### Zeabur
|
|
||||||
|
|
||||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
|
||||||
|
|
||||||
[](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>
|
||||||
|
|||||||
+36
-10
@@ -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`);
|
||||||
|
|||||||
+8
-3
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -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);
|
||||||
|
|||||||
+13
-5
@@ -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"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+218
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+144
-22
@@ -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,
|
||||||
|
|||||||
+118
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+11
@@ -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}
|
||||||
|
|||||||
+13
@@ -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,
|
||||||
|
});
|
||||||
+22
@@ -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;
|
||||||
+8
-1
@@ -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
|
||||||
|
|||||||
+23
@@ -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;
|
||||||
+3
-1
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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"),
|
||||||
|
|||||||
+59
-4
@@ -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", () => {
|
||||||
|
|||||||
+48
-9
@@ -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({
|
||||||
|
|||||||
+26
-1
@@ -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}
|
||||||
|
|||||||
-208
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {
|
||||||
|
|||||||
+42
-1
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+488
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+283
@@ -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),
|
||||||
|
};
|
||||||
|
};
|
||||||
+84
@@ -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),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+28
@@ -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]",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const getBillingFallbackPath = (environmentId: string, isFormbricksCloud: boolean): string => {
|
||||||
|
const settingsPath = isFormbricksCloud ? "billing" : "enterprise";
|
||||||
|
return `/environments/${environmentId}/settings/${settingsPath}`;
|
||||||
|
};
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { capturePostHogEvent } from "./capture";
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user