mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-16 03:21:30 -05:00
Compare commits
1 Commits
release/4.
...
feat/api-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f737c8b76f |
@@ -1,9 +0,0 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "formbricks"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install
|
||||
pnpm dev:setup
|
||||
'''
|
||||
41
.env.example
41
.env.example
@@ -94,12 +94,6 @@ EMAIL_VERIFICATION_DISABLED=1
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
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_AUTH_DISABLED=1
|
||||
|
||||
@@ -138,31 +132,6 @@ AZUREAD_CLIENT_ID=
|
||||
AZUREAD_CLIENT_SECRET=
|
||||
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
|
||||
# OIDC_CLIENT_ID=
|
||||
# OIDC_CLIENT_SECRET=
|
||||
@@ -216,13 +185,9 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
||||
# TELEMETRY_DISABLED=1
|
||||
|
||||
# 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
|
||||
# Public unauthenticated IP-based rate limits can be handled by an edge provider.
|
||||
# Supported values: none, cloudflare, cloudarmor, envoy
|
||||
# EDGE_RATE_LIMIT_PROVIDER=none
|
||||
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ yarn-error.log*
|
||||
.direnv
|
||||
|
||||
# Playwright
|
||||
**/test-results/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
#!/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
|
||||
pnpm lint-staged
|
||||
26
README.md
26
README.md
@@ -127,10 +127,34 @@ 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.
|
||||
|
||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||
|
||||
#### Docker
|
||||
|
||||
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
|
||||
|
||||
### Prerequisites
|
||||
@@ -223,4 +247,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.
|
||||
|
||||
<a id="readme-de"></a>
|
||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||
|
||||
@@ -26,8 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isMember, isBilling } = getAccessFlags(membership?.role);
|
||||
const isMembershipPending = membership?.role === undefined;
|
||||
const { isMember } = getAccessFlags(membership?.role);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
@@ -46,8 +45,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
isMember={isMember}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
environments={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,10 +75,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
publicDomain={publicDomain}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isLicenseActive={active}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -2,59 +2,42 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
FoldersIcon,
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
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 { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
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 { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -66,31 +49,8 @@ interface NavigationProps {
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
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 = ({
|
||||
environment,
|
||||
organization,
|
||||
@@ -100,10 +60,6 @@ export const MainNavigation = ({
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
publicDomain,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isAccessControlAllowed,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -113,12 +69,7 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
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 { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -155,7 +106,6 @@ export const MainNavigation = ({
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
@@ -165,17 +115,22 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
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"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/workspace"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname, isMembershipPending, isBilling]
|
||||
[t, environment.id, pathname, isFormbricksCloud]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
@@ -198,183 +153,6 @@ 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(() => {
|
||||
async function loadReleases() {
|
||||
const res = await getLatestStableFbReleaseAction();
|
||||
@@ -404,79 +182,7 @@ export const MainNavigation = ({
|
||||
organization.billing?.stripe?.trialEnd,
|
||||
]);
|
||||
|
||||
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;
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -516,24 +222,24 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
{!isBilling && (
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -557,210 +263,38 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
</span>
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<div
|
||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||
<p
|
||||
title={user?.email}
|
||||
className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
||||
className={cn(
|
||||
"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>}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
<ChevronRightIcon
|
||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
@@ -769,6 +303,8 @@ export const MainNavigation = ({
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
@@ -782,6 +318,7 @@ export const MainNavigation = ({
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
@@ -804,28 +341,6 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
</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,7 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface NavigationLinkProps {
|
||||
@@ -11,8 +10,6 @@ interface NavigationLinkProps {
|
||||
children: React.ReactNode;
|
||||
linkText: string;
|
||||
isTextVisible: boolean;
|
||||
disabled?: boolean;
|
||||
disabledMessage?: string;
|
||||
}
|
||||
|
||||
export const NavigationLink = ({
|
||||
@@ -22,34 +19,10 @@ export const NavigationLink = ({
|
||||
children,
|
||||
linkText,
|
||||
isTextVisible = true,
|
||||
disabled = false,
|
||||
disabledMessage,
|
||||
}: NavigationLinkProps) => {
|
||||
const tooltipText = disabled ? disabledMessage || linkText : linkText;
|
||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||
const inactiveClass =
|
||||
"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 (
|
||||
<>
|
||||
@@ -57,37 +30,35 @@ export const NavigationLink = ({
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
|
||||
{disabled ? (
|
||||
<div className="flex items-center">{children}</div>
|
||||
) : (
|
||||
<Link href={href}>{children}</Link>
|
||||
)}
|
||||
<li
|
||||
className={cn(
|
||||
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
|
||||
isActive ? activeClass : inactiveClass
|
||||
)}>
|
||||
<Link href={href} className="flex items-center">
|
||||
{children}
|
||||
</Link>
|
||||
</li>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{tooltipText}</TooltipContent>
|
||||
<TooltipContent side="right">{linkText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
|
||||
{disabled ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
{children}
|
||||
{label}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
{disabledMessage || linkText}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Link href={href} className="flex items-center">
|
||||
{children}
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
<li
|
||||
className={cn(
|
||||
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
||||
isActive ? activeClass : inactiveClass
|
||||
)}>
|
||||
<Link href={href} className="flex items-center">
|
||||
{children}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 flex transition-opacity duration-100",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{linkText}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -31,8 +31,7 @@ export const TopControlBar = ({
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
}: TopControlBarProps) => {
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const isMembershipPending = membershipRole === undefined;
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { environment } = useEnvironment();
|
||||
|
||||
return (
|
||||
@@ -50,8 +49,6 @@ export const TopControlBar = ({
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { useOrganization } from "../context/environment-context";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
@@ -36,7 +35,6 @@ interface OrganizationBreadcrumbProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isMember: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMembershipPending: boolean;
|
||||
}
|
||||
|
||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||
@@ -58,7 +56,6 @@ export const OrganizationBreadcrumb = ({
|
||||
isFormbricksCloud,
|
||||
isMember,
|
||||
isOwnerOrManager,
|
||||
isMembershipPending,
|
||||
}: OrganizationBreadcrumbProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||
@@ -145,10 +142,7 @@ export const OrganizationBreadcrumb = ({
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||
disabled: isMembershipPending || !isOwnerOrManager,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
@@ -166,11 +160,7 @@ export const OrganizationBreadcrumb = ({
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -252,30 +242,14 @@ export const OrganizationBreadcrumb = ({
|
||||
|
||||
{organizationSettings.map((setting) => {
|
||||
return setting.hidden ? null : (
|
||||
<div key={setting.id}>
|
||||
{setting.disabled ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
{setting.disabledMessage}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||
onClick={() => handleSettingChange(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||
hidden={setting.hidden}
|
||||
onClick={() => handleSettingChange(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,6 @@ interface ProjectAndOrgSwitchProps {
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMember: boolean;
|
||||
isBilling: boolean;
|
||||
isMembershipPending: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
@@ -37,8 +35,6 @@ export const ProjectAndOrgSwitch = ({
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
isBilling,
|
||||
isMembershipPending,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
@@ -54,7 +50,6 @@ export const ProjectAndOrgSwitch = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMembershipPending={isMembershipPending}
|
||||
/>
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
@@ -68,8 +63,6 @@ export const ProjectAndOrgSwitch = ({
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
/>
|
||||
)}
|
||||
{showEnvironmentBreadcrumb && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useProject } from "../context/environment-context";
|
||||
|
||||
@@ -34,8 +33,6 @@ interface ProjectBreadcrumbProps {
|
||||
currentEnvironmentId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
isEnvironmentBreadcrumbVisible: boolean;
|
||||
isBilling: boolean;
|
||||
isMembershipPending: boolean;
|
||||
}
|
||||
|
||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||
@@ -59,8 +56,6 @@ export const ProjectBreadcrumb = ({
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
isEnvironmentBreadcrumbVisible,
|
||||
isBilling,
|
||||
isMembershipPending,
|
||||
}: ProjectBreadcrumbProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||
@@ -139,10 +134,6 @@ export const ProjectBreadcrumb = ({
|
||||
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) {
|
||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||
@@ -207,7 +198,7 @@ export const ProjectBreadcrumb = ({
|
||||
id="projectDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{projectName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||
@@ -220,7 +211,7 @@ export const ProjectBreadcrumb = ({
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<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} />
|
||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_workspace")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
@@ -256,24 +247,7 @@ export const ProjectBreadcrumb = ({
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{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>
|
||||
) : (
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
@@ -290,30 +264,13 @@ export const ProjectBreadcrumb = ({
|
||||
{t("common.workspace_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<div key={setting.id}>
|
||||
{areProjectSettingsDisabled ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -13,7 +12,11 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
|
||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
} else {
|
||||
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||
|
||||
@@ -10,16 +10,15 @@ import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
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 { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||
return {
|
||||
@@ -86,15 +85,11 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await requestPasswordReset(ctx.user, "profile");
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
|
||||
@@ -116,14 +116,10 @@ export const EditProfileDetailsForm = ({
|
||||
setShowModal(true);
|
||||
} else {
|
||||
try {
|
||||
const result = await updateUserAction({
|
||||
await updateUserAction({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset(data);
|
||||
|
||||
@@ -22,9 +22,8 @@ export const OrganizationSettingsNavbar = ({
|
||||
loading,
|
||||
}: OrganizationSettingsNavbarProps) => {
|
||||
const pathname = usePathname();
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const isMembershipPending = membershipRole === undefined || loading;
|
||||
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
||||
const isPricingDisabled = isMember;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigation = [
|
||||
@@ -46,10 +45,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environmentId}/settings/api-keys`,
|
||||
current: pathname?.includes("/api-keys"),
|
||||
disabled: isMembershipPending || !isOwnerOrManager,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
hidden: !isOwner,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
@@ -62,18 +58,14 @@ export const OrganizationSettingsNavbar = ({
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
hidden: !isFormbricksCloud || loading,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${environmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
current: pathname?.includes("/enterprise"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { updateOrganizationAISettingsAction } from "./actions";
|
||||
import { ZOrganizationAISettingsInput } from "./schemas";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isInstanceAIConfigured: vi.fn(),
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
deleteOrganization: vi.fn(),
|
||||
getOrganization: vi.fn(),
|
||||
getIsMultiOrgEnabled: vi.fn(),
|
||||
getTranslate: vi.fn(),
|
||||
updateOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
authenticatedActionClient: {
|
||||
inputSchema: vi.fn(() => ({
|
||||
action: vi.fn((fn) => fn),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
deleteOrganization: mocks.deleteOrganization,
|
||||
getOrganization: mocks.getOrganization,
|
||||
updateOrganization: mocks.updateOrganization,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/service", () => ({
|
||||
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
getTranslate: mocks.getTranslate,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
|
||||
}));
|
||||
|
||||
const organizationId = "cm9gptbhg0000192zceq9ayuc";
|
||||
|
||||
describe("organization AI settings actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
||||
values ? `${key}:${JSON.stringify(values)}` : key
|
||||
);
|
||||
mocks.updateOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("accepts AI toggle updates", () => {
|
||||
expect(
|
||||
ZOrganizationAISettingsInput.parse({
|
||||
isAISmartToolsEnabled: true,
|
||||
})
|
||||
).toEqual({
|
||||
isAISmartToolsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
|
||||
const ctx = {
|
||||
user: { id: "user_1", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
};
|
||||
const parsedInput = {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationAISettingsInput,
|
||||
data: parsedInput.data,
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
|
||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
|
||||
expect(ctx.auditLoggingCtx).toMatchObject({
|
||||
organizationId,
|
||||
oldObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
newObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("propagates authorization failures so members cannot update AI settings", async () => {
|
||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
|
||||
await expect(
|
||||
updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_member", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects enabling AI when the instance AI provider is not configured", async () => {
|
||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||
|
||||
await expect(
|
||||
updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_owner", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(OperationNotAllowedError);
|
||||
|
||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows enabling AI when the instance configuration is valid", async () => {
|
||||
await updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_owner", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
||||
isAISmartToolsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
|
||||
mocks.getOrganization.mockResolvedValueOnce({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||
|
||||
await updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_owner", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
||||
isAISmartToolsEnabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,44 +2,13 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
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 { 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({
|
||||
organizationId: ZId,
|
||||
@@ -49,114 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationNameAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
|
||||
}) =>
|
||||
updateOrganizationAction({
|
||||
ctx,
|
||||
organizationId: parsedInput.organizationId,
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
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"],
|
||||
});
|
||||
}
|
||||
)
|
||||
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZDeleteOrganizationAction = z.object({
|
||||
@@ -168,10 +49,7 @@ export const deleteOrganizationAction = authenticatedActionClient
|
||||
.action(
|
||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) {
|
||||
const t = await getTranslate(ctx.user.locale);
|
||||
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
|
||||
}
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
|
||||
interface AISettingsToggleProps {
|
||||
organization: TOrganization;
|
||||
membershipRole?: TOrganizationRole;
|
||||
isInstanceAIConfigured: boolean;
|
||||
}
|
||||
|
||||
export const AISettingsToggle = ({
|
||||
organization,
|
||||
membershipRole,
|
||||
isInstanceAIConfigured,
|
||||
}: Readonly<AISettingsToggleProps>) => {
|
||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const canEdit = isOwner || isManager;
|
||||
const aiEnablementState = getOrganizationAIEnablementState({
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
|
||||
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
|
||||
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
|
||||
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
|
||||
currentValue: organization.isAISmartToolsEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
||||
currentValue: organization.isAIDataAnalysisEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
|
||||
const handleToggle = async (
|
||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
||||
checked: boolean
|
||||
) => {
|
||||
if (checked && !aiEnablementState.canEnableFeatures) {
|
||||
toast.error(aiEnablementBlockedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingField(field);
|
||||
try {
|
||||
const data =
|
||||
field === "isAISmartToolsEnabled"
|
||||
? { isAISmartToolsEnabled: checked }
|
||||
: { isAIDataAnalysisEnabled: checked };
|
||||
const response = await updateOrganizationAISettingsAction({
|
||||
organizationId: organization.id,
|
||||
data,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(response));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setLoadingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showInstanceConfigWarning && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={displayedSmartToolsValue}
|
||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
||||
htmlId="ai-smart-tools-toggle"
|
||||
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
||||
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
||||
disabled={isToggleDisabled}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={displayedDataAnalysisValue}
|
||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
||||
htmlId="ai-data-analysis-toggle"
|
||||
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
||||
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
||||
disabled={isToggleDisabled}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
{!canEdit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
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 { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -12,7 +11,6 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import packageJson from "@/package.json";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { AISettingsToggle } from "./components/AISettingsToggle";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
@@ -62,15 +60,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
</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
|
||||
organization={organization}
|
||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -29,7 +29,6 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
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 { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -202,13 +201,7 @@ export const ResponseTable = ({
|
||||
};
|
||||
|
||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||
const result = await deleteResponseAction({
|
||||
responseId,
|
||||
decrementQuotas: params?.decrementQuotas ?? false,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
throw new Error(getFormattedErrorMessage(result));
|
||||
}
|
||||
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
|
||||
@@ -107,9 +107,7 @@ export const SummaryMetadata = ({
|
||||
label={t("environments.surveys.summary.time_to_complete")}
|
||||
percentage={null}
|
||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
|
||||
defaultValue: "Average time to complete the survey.",
|
||||
})}
|
||||
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -163,7 +163,6 @@ export const PersonalLinksTab = ({
|
||||
<UpgradePrompt
|
||||
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||
feature="personal_links"
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
|
||||
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
|
||||
});
|
||||
|
||||
test("calculates meta correctly", () => {
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
||||
expect(meta.displayCount).toBe(10);
|
||||
expect(meta.totalResponses).toBe(3);
|
||||
expect(meta.startsPercentage).toBe(30);
|
||||
@@ -178,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
|
||||
});
|
||||
|
||||
test("handles zero display count", () => {
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
|
||||
expect(meta.startsPercentage).toBe(0);
|
||||
expect(meta.completedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
test("handles zero responses", () => {
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
|
||||
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
||||
expect(meta.totalResponses).toBe(0);
|
||||
expect(meta.completedResponses).toBe(0);
|
||||
expect(meta.dropOffCount).toBe(0);
|
||||
@@ -274,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
|
||||
expect(dropOff[1].impressions).toBe(2);
|
||||
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].ttc).toBe(10); // block-level TTC uses max block time per response
|
||||
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
|
||||
});
|
||||
|
||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||
|
||||
@@ -51,32 +51,7 @@ interface TSurveySummaryResponse {
|
||||
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 = (
|
||||
survey: TSurvey,
|
||||
responses: TSurveySummaryResponse[],
|
||||
displayCount: number,
|
||||
quotas: TSurveySummary["quotas"]
|
||||
@@ -85,15 +60,9 @@ export const getSurveySummaryMeta = (
|
||||
|
||||
let ttcResponseCount = 0;
|
||||
const ttcSum = responses.reduce((acc, response) => {
|
||||
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) {
|
||||
if (response.ttc?._total) {
|
||||
ttcResponseCount++;
|
||||
return acc + responseTtcTotal;
|
||||
return acc + response.ttc._total;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -148,16 +117,12 @@ export const getSurveySummaryDropOff = (
|
||||
let dropOffArr = 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[];
|
||||
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion per element
|
||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
const blockId = elementIdToBlockId[elementId];
|
||||
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
|
||||
if (blockTtc > 0) {
|
||||
totalTtc[elementId] += blockTtc;
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
responseCounts[elementId]++;
|
||||
}
|
||||
});
|
||||
@@ -1009,8 +974,10 @@ export const getSurveySummary = reactCache(
|
||||
]);
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
|
||||
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
|
||||
const [meta, elementSummary] = await Promise.all([
|
||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||
getElementSummary(survey, elements, responses, dropOff),
|
||||
]);
|
||||
|
||||
return {
|
||||
meta,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
@@ -24,11 +23,9 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -42,20 +39,11 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getResponseDownloadFile(
|
||||
return await getResponseDownloadFile(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.format,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "responses_exported", {
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZGetSurveyFilterDataAction = z.object({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -55,25 +54,6 @@ export enum OptionsType {
|
||||
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 = {
|
||||
label: string;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={
|
||||
<p className="text-sm font-medium text-slate-600">
|
||||
{getOptionsTypeTranslationKey(data.header, t)}
|
||||
</p>
|
||||
}>
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
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,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
@@ -46,12 +45,6 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -13,9 +13,7 @@ import notionLogo from "@/images/notion.png";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import WebhookLogo from "@/images/webhook.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
@@ -55,7 +53,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
integrations.some((integration) => integration.type === type);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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,20 +51,8 @@ vi.mock("@/lib/env", () => ({
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
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
|
||||
const fetchMock = vi.fn();
|
||||
@@ -211,14 +199,6 @@ describe("sendTelemetryEvents", () => {
|
||||
test("should handle telemetry send failure and apply cooldown", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
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");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
@@ -241,7 +221,6 @@ describe("sendTelemetryEvents", () => {
|
||||
expect.objectContaining({
|
||||
error: networkError,
|
||||
message: "Network error",
|
||||
hashedLicenseKey: "hashed-test-license-key",
|
||||
}),
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
@@ -263,14 +242,6 @@ describe("sendTelemetryEvents", () => {
|
||||
test("should skip if no organization exists", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
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");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
@@ -305,113 +276,4 @@ describe("sendTelemetryEvents", () => {
|
||||
// This might be a bug, but we test the actual behavior
|
||||
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,11 +2,8 @@ import { IntegrationType } from "@prisma/client";
|
||||
import { createCacheKey, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { E2E_TESTING, IS_DEVELOPMENT, TELEMETRY_DISABLED } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getInstanceInfo } from "@/lib/instance";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -27,31 +24,8 @@ let nextTelemetryCheck = 0;
|
||||
* 2. Redis check (shared across instances, persists across restarts)
|
||||
* 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 () => {
|
||||
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();
|
||||
|
||||
// ============================================================
|
||||
@@ -65,18 +39,7 @@ export const sendTelemetryEvents = async () => {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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)
|
||||
// CHECK 2: Redis Check (Shared State)
|
||||
// ============================================================
|
||||
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
||||
// This persists across restarts and works in multi-instance deployments.
|
||||
@@ -103,7 +66,7 @@ export const sendTelemetryEvents = async () => {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 4: Distributed Lock (Prevent Concurrent Execution)
|
||||
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
|
||||
// ============================================================
|
||||
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
||||
// How it works:
|
||||
@@ -137,7 +100,7 @@ export const sendTelemetryEvents = async () => {
|
||||
// Log as warning since telemetry is non-essential
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(
|
||||
{ error: e, message: errorMessage, lastSent, now, hashedLicenseKey },
|
||||
{ error: e, message: errorMessage, lastSent, now },
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
|
||||
@@ -155,7 +118,7 @@ export const sendTelemetryEvents = async () => {
|
||||
// Log as warning since telemetry is non-essential functionality
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
{ error, message: errorMessage, timestamp: Date.now(), hashedLicenseKey },
|
||||
{ error, message: errorMessage, timestamp: Date.now() },
|
||||
"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 { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -24,7 +24,6 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const requestHeaders = await headers();
|
||||
@@ -300,18 +299,6 @@ export const POST = async (request: Request) => {
|
||||
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
|
||||
await sendTelemetryEvents();
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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,26 +6,10 @@ import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
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 eventId = req.headers.get("x-request-id") ?? undefined;
|
||||
|
||||
@@ -33,6 +17,44 @@ const handler = async (req: Request, ctx: any) => {
|
||||
...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) {
|
||||
let result: any = params.session;
|
||||
let error: any = undefined;
|
||||
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
|
||||
}) {
|
||||
let result: boolean | string = true;
|
||||
let error: any = undefined;
|
||||
const authMethod = getAuthMethod(account);
|
||||
let authMethod = "unknown";
|
||||
|
||||
try {
|
||||
if (baseAuthOptions.callbacks?.signIn) {
|
||||
@@ -80,6 +102,15 @@ const handler = async (req: Request, ctx: any) => {
|
||||
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) {
|
||||
error = err;
|
||||
result = false;
|
||||
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (result === false) {
|
||||
queueAuditEventBackground({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: user?.id ?? UNKNOWN_DATA,
|
||||
targetId: user?.id ?? UNKNOWN_DATA,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "failure",
|
||||
userType: "user",
|
||||
newObject: {
|
||||
...user,
|
||||
authMethod,
|
||||
provider: account?.provider,
|
||||
...(error instanceof Error ? { errorMessage: error.message } : {}),
|
||||
},
|
||||
eventId,
|
||||
});
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
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",
|
||||
const status: TAuditStatus = result === false ? "failure" : "success";
|
||||
const auditLog = {
|
||||
action: "signedIn" as const,
|
||||
targetType: "user" as const,
|
||||
userId: user?.id ?? UNKNOWN_DATA,
|
||||
targetId: user?.id ?? UNKNOWN_DATA,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "success",
|
||||
userType: "user",
|
||||
status,
|
||||
userType: "user" as const,
|
||||
newObject: {
|
||||
...user,
|
||||
authMethod: getAuthMethod(account),
|
||||
authMethod,
|
||||
provider: account?.provider,
|
||||
sessionStrategy: "database",
|
||||
isNewUser: isNewUser ?? false,
|
||||
...(error ? { errorMessage: error.message } : {}),
|
||||
},
|
||||
});
|
||||
...(status === "failure" ? { eventId } : {}),
|
||||
};
|
||||
|
||||
queueAuditEventBackground(auditLog);
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { google } from "googleapis";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
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";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -85,16 +82,6 @@ export const GET = async (req: Request) => {
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||
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(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/typ
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
@@ -37,11 +36,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
IS_PRODUCTION: true,
|
||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||
POSTHOG_KEY: "phc_test_key",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @formbricks/cache
|
||||
@@ -82,8 +76,7 @@ const mockOrganization: TOrganization = {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
@@ -309,38 +302,4 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
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,8 +3,7 @@ import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_RECAPTCHA_CONFIGURED, POSTHOG_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { getEnvironmentStateData } from "./data";
|
||||
|
||||
/**
|
||||
@@ -31,14 +30,6 @@ export const getEnvironmentState = async (
|
||||
where: { id: environmentId },
|
||||
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
|
||||
|
||||
@@ -86,11 +86,9 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
error: err,
|
||||
url: req.url,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
@@ -98,10 +96,9 @@ export const GET = withV1ApiWrapper({
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
"An error occurred while processing your request.",
|
||||
err instanceof Error ? err.message : "Unknown error occurred",
|
||||
true
|
||||
),
|
||||
error,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
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),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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,11 +1,235 @@
|
||||
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 { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { putResponseHandler } from "./lib/put-response-handler";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } 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 "./lib/response";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
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({
|
||||
handler: putResponseHandler,
|
||||
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
|
||||
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 { ZUploadPrivateFileRequest } from "@formbricks/types/storage";
|
||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
||||
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
|
||||
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 { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -30,27 +30,33 @@ export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { environmentId } = params;
|
||||
const parsedInputResult = await parseAndValidateJsonBody({
|
||||
request: req,
|
||||
schema: ZUploadPrivateFileRequest,
|
||||
buildInput: (jsonInput) => ({
|
||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
||||
environmentId,
|
||||
}),
|
||||
let jsonInput: TUploadPrivateFileRequest;
|
||||
|
||||
try {
|
||||
jsonInput = await req.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
}
|
||||
|
||||
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if ("response" in parsedInputResult) {
|
||||
if (parsedInputResult.issue === "invalid_json") {
|
||||
logger.error({ error: parsedInputResult.details, url: req.url }, "Error parsing JSON input");
|
||||
} else {
|
||||
logger.error(
|
||||
{ error: parsedInputResult.details, url: req.url },
|
||||
"Fields are missing or incorrectly formatted"
|
||||
);
|
||||
}
|
||||
if (!parsedInputResult.success) {
|
||||
const errorDetails = transformErrorToDetails(parsedInputResult.error);
|
||||
|
||||
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
|
||||
|
||||
return {
|
||||
response: parsedInputResult.response,
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
errorDetails,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,14 +105,9 @@ export const POST = withV1ApiWrapper({
|
||||
if (!signedUrlResponse.ok) {
|
||||
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
|
||||
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
|
||||
return errorResponse.status >= 500
|
||||
? {
|
||||
response: errorResponse,
|
||||
error: signedUrlResponse.error,
|
||||
}
|
||||
: {
|
||||
response: errorResponse,
|
||||
};
|
||||
return {
|
||||
response: errorResponse,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,8 +6,6 @@ import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const getEmail = async (token: string) => {
|
||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||
@@ -88,17 +86,6 @@ export const GET = withV1ApiWrapper({
|
||||
},
|
||||
};
|
||||
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 {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -12,8 +11,6 @@ import {
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -99,16 +96,6 @@ export const GET = withV1ApiWrapper({
|
||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||
|
||||
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 {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
TIntegrationSlackConfig,
|
||||
TIntegrationSlackConfigData,
|
||||
@@ -9,8 +8,6 @@ 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 { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -107,16 +104,6 @@ export const GET = withV1ApiWrapper({
|
||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||
|
||||
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 {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
||||
|
||||
@@ -49,8 +49,7 @@ const mockOrganization: TOrganization = {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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,12 +1,14 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TDisplayCreateInputV2,
|
||||
ZDisplayCreateInputV2,
|
||||
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
||||
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 {
|
||||
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 { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -16,29 +18,6 @@ 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> => {
|
||||
return responses.successResponse(
|
||||
{},
|
||||
@@ -50,41 +29,48 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
|
||||
|
||||
if ("response" in validatedInput) {
|
||||
return validatedInput.response;
|
||||
try {
|
||||
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientDisplays, rateLimitConfigs.api.client);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { displayInputData } = validatedInput;
|
||||
const params = await context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInputV2.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (inputValidation.data.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);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
|
||||
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
|
||||
} 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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
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 { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
@@ -14,6 +14,11 @@ import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
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 { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||
@@ -25,86 +30,87 @@ interface Context {
|
||||
}>;
|
||||
}
|
||||
|
||||
type TResponseSurvey = NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
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"
|
||||
);
|
||||
};
|
||||
|
||||
type TValidatedResponseInputResult =
|
||||
| {
|
||||
environmentId: string;
|
||||
responseInputData: TResponseInputV2;
|
||||
}
|
||||
| { response: Response };
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
try {
|
||||
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientResponses, rateLimitConfigs.api.client);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const getCountry = (requestHeaders: Headers): string | undefined =>
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
const params = await context.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await request.json();
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse(
|
||||
"Invalid JSON in request body",
|
||||
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const getUnexpectedPublicErrorResponse = (): Response =>
|
||||
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
||||
|
||||
const parseAndValidateResponseInput = async (
|
||||
request: Request,
|
||||
environmentId: string
|
||||
): Promise<TValidatedResponseInputResult> => {
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const responseInputValidation = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: ZResponseInputV2,
|
||||
buildInput: (jsonInput) => ({
|
||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
||||
environmentId,
|
||||
}),
|
||||
malformedJsonMessage: "Invalid JSON in request body",
|
||||
});
|
||||
|
||||
if ("response" in responseInputValidation) {
|
||||
return responseInputValidation;
|
||||
if (!responseInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
environmentId,
|
||||
responseInputData: responseInputValidation.data,
|
||||
};
|
||||
};
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const getContactsDisabledResponse = async (
|
||||
environmentId: string,
|
||||
contactId: string | null | undefined
|
||||
): Promise<Response | null> => {
|
||||
if (!contactId) {
|
||||
return null;
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
|
||||
return isContactsEnabled
|
||||
? null
|
||||
: responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
};
|
||||
|
||||
const validateResponseSubmission = async (
|
||||
environmentId: string,
|
||||
responseInputData: TResponseInputV2,
|
||||
survey: TResponseSurvey
|
||||
): Promise<Response | null> => {
|
||||
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInputData);
|
||||
if (surveyCheckResult) {
|
||||
return surveyCheckResult;
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
|
||||
}
|
||||
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
|
||||
if (surveyCheckResult) return surveyCheckResult;
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: responseInputData.data,
|
||||
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
||||
@@ -121,6 +127,7 @@ const validateResponseSubmission = async (
|
||||
);
|
||||
}
|
||||
|
||||
// Validate response data against validation rules
|
||||
const validationErrors = validateResponseData(
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
@@ -128,29 +135,15 @@ const validateResponseSubmission = async (
|
||||
survey.questions
|
||||
);
|
||||
|
||||
return validationErrors
|
||||
? responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
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);
|
||||
if (validationErrors) {
|
||||
return responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
let response: TResponseWithQuotaFull;
|
||||
try {
|
||||
const meta: TResponseInputV2["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
@@ -160,115 +153,54 @@ const createResponseForRequest = async ({
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country,
|
||||
country: country,
|
||||
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) {
|
||||
meta.ipAddress = await getClientIpFromHeaders();
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
return await createResponseWithQuotaEvaluation({
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
|
||||
const response = getUnexpectedPublicErrorResponse();
|
||||
reportApiError({
|
||||
request,
|
||||
status: response.status,
|
||||
error,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
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> => {
|
||||
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
|
||||
logger.error({ error, url: request.url }, "Error creating response");
|
||||
return responses.internalServerErrorResponse(
|
||||
error instanceof Error ? error.message : "Unknown error occurred"
|
||||
);
|
||||
if (contactsDisabledResponse) {
|
||||
return contactsDisabledResponse;
|
||||
}
|
||||
}
|
||||
const { quotaFull, ...responseData } = response;
|
||||
|
||||
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;
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (responseData.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
event: "responseFinished",
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
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]",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
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,20 +6,36 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import { responses } from "./response";
|
||||
|
||||
// Mocks
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
__esModule: true,
|
||||
queueAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn(),
|
||||
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
|
||||
UNKNOWN_DATA: "unknown",
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback) => {
|
||||
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 mockContextualLoggerWarn = 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", () => {
|
||||
const mockWithContextInstance = vi.fn(() => ({
|
||||
@@ -60,10 +76,13 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: 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", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
@@ -74,6 +93,7 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
@@ -102,6 +122,7 @@ describe("withV1ApiWrapper", () => {
|
||||
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
EDGE_RATE_LIMIT_PROVIDER: "none",
|
||||
IS_PRODUCTION: true,
|
||||
SENTRY_DSN: "dsn",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
@@ -109,14 +130,22 @@ describe("withV1ApiWrapper", () => {
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -136,7 +165,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
url: V1_MANAGEMENT_SURVEYS_URL,
|
||||
url: "https://api.test/v1/management/surveys",
|
||||
headers: new Map([["x-request-id", "abc-123"]]),
|
||||
});
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
@@ -158,41 +187,19 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -211,7 +218,7 @@ describe("withV1ApiWrapper", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
await wrapped(req, undefined);
|
||||
@@ -234,11 +241,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on thrown error", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -256,7 +265,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
url: V1_MANAGEMENT_SURVEYS_URL,
|
||||
url: "https://api.test/v1/management/surveys",
|
||||
headers: new Map([["x-request-id", "err-1"]]),
|
||||
});
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
@@ -285,86 +294,18 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
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",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -383,7 +324,7 @@ describe("withV1ApiWrapper", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
await wrapped(req, undefined);
|
||||
@@ -408,17 +349,20 @@ describe("withV1ApiWrapper", () => {
|
||||
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
EDGE_RATE_LIMIT_PROVIDER: "none",
|
||||
IS_PRODUCTION: true,
|
||||
SENTRY_DSN: "dsn",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -433,7 +377,7 @@ describe("withV1ApiWrapper", () => {
|
||||
response: responses.internalServerErrorResponse("fail"),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
await wrapped(req, undefined);
|
||||
|
||||
@@ -441,10 +385,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("handles client-side API routes without authentication", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { applyPublicIpRateLimitForRoute } = await import(
|
||||
"@/modules/core/rate-limit/public-edge-rate-limit"
|
||||
);
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
@@ -453,7 +400,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.mocked(applyPublicIpRateLimitForRoute).mockResolvedValue("app");
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
@@ -471,11 +418,17 @@ describe("withV1ApiWrapper", () => {
|
||||
auditLog: undefined,
|
||||
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 () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -487,7 +440,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
@@ -497,8 +450,9 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { getServerSession } = await import("next-auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -530,8 +484,9 @@ describe("withV1ApiWrapper", () => {
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -546,7 +501,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
@@ -556,11 +511,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -574,7 +531,7 @@ describe("withV1ApiWrapper", () => {
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
await wrapped(req, undefined);
|
||||
@@ -593,7 +550,7 @@ describe("buildAuditLogBaseObject", () => {
|
||||
test("creates audit log base object with correct structure", async () => {
|
||||
const { buildAuditLogBaseObject } = await import("./with-api-logging");
|
||||
|
||||
const result = buildAuditLogBaseObject("created", "survey", V1_MANAGEMENT_SURVEYS_URL);
|
||||
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys");
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "created",
|
||||
@@ -605,7 +562,7 @@ describe("buildAuditLogBaseObject", () => {
|
||||
oldObject: undefined,
|
||||
newObject: undefined,
|
||||
userType: "api",
|
||||
apiUrl: V1_MANAGEMENT_SURVEYS_URL,
|
||||
apiUrl: "https://api.test/v1/management/surveys",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
AuthenticationMethod,
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
isIntegrationRoute,
|
||||
isManagementApiRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { 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 { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
@@ -33,10 +34,7 @@ export interface THandlerParams<TProps = unknown> {
|
||||
}
|
||||
|
||||
// Interface for wrapper function parameters
|
||||
export interface TWithV1ApiWrapperParams<
|
||||
TResult extends { response: Response; error?: unknown },
|
||||
TProps = unknown,
|
||||
> {
|
||||
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> {
|
||||
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
|
||||
action?: TAuditAction;
|
||||
targetType?: TAuditTarget;
|
||||
@@ -57,14 +55,22 @@ enum ApiV1RouteTypeEnum {
|
||||
/**
|
||||
* Apply client-side API rate limiting (IP-based)
|
||||
*/
|
||||
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
const applyClientRateLimit = async (
|
||||
req: NextRequest,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
): Promise<void> => {
|
||||
await applyPublicIpRateLimitForRoute(
|
||||
req.nextUrl.pathname,
|
||||
req.method,
|
||||
customRateLimitConfig ?? rateLimitConfigs.api.client
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle rate limiting based on authentication and API type
|
||||
*/
|
||||
const handleRateLimiting = async (
|
||||
req: NextRequest,
|
||||
authentication: TApiV1Authentication,
|
||||
routeType: ApiV1RouteTypeEnum,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
@@ -84,7 +90,7 @@ const handleRateLimiting = async (
|
||||
}
|
||||
|
||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||
await applyClientRateLimit(customRateLimitConfig);
|
||||
await applyClientRateLimit(req, customRateLimitConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded");
|
||||
@@ -96,7 +102,7 @@ const handleRateLimiting = async (
|
||||
/**
|
||||
* Execute handler with error handling
|
||||
*/
|
||||
const executeHandler = async <TResult extends { response: Response; error?: unknown }, TProps>(
|
||||
const executeHandler = async <TResult extends { response: Response }, TProps>(
|
||||
handler: (params: THandlerParams<TProps>) => Promise<TResult>,
|
||||
req: NextRequest,
|
||||
props: TProps,
|
||||
@@ -161,12 +167,34 @@ const handleAuthentication = async (
|
||||
/**
|
||||
* Log error details to system logger and Sentry
|
||||
*/
|
||||
const logErrorDetails = (res: Response, req: NextRequest, error?: unknown): void => {
|
||||
reportApiError({
|
||||
request: req,
|
||||
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => {
|
||||
const logContext = {
|
||||
correlationId,
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,7 +204,7 @@ const processResponse = async (
|
||||
res: Response,
|
||||
req: NextRequest,
|
||||
auditLog?: TApiAuditLog,
|
||||
error?: unknown
|
||||
error?: any
|
||||
): Promise<void> => {
|
||||
const correlationId = req.headers.get("x-request-id") ?? "";
|
||||
|
||||
@@ -191,7 +219,7 @@ const processResponse = async (
|
||||
|
||||
// Handle error logging
|
||||
if (!res.ok) {
|
||||
logErrorDetails(res, req, error);
|
||||
logErrorDetails(res, req, correlationId, error);
|
||||
}
|
||||
|
||||
// Queue audit event if enabled and audit log exists
|
||||
@@ -248,7 +276,7 @@ const getRouteType = (
|
||||
* @returns Wrapped handler function that returns the final HTTP response
|
||||
*
|
||||
*/
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
|
||||
@@ -286,17 +314,21 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
|
||||
|
||||
// === Rate Limiting ===
|
||||
if (isRateLimited) {
|
||||
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
|
||||
const rateLimitResponse = await handleRateLimiting(
|
||||
req,
|
||||
authentication,
|
||||
routeType,
|
||||
customRateLimitConfig
|
||||
);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
}
|
||||
|
||||
// === Handler Execution ===
|
||||
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
|
||||
const res = result.response;
|
||||
const reportedError = result.error ?? error;
|
||||
|
||||
// === Response Processing & Logging ===
|
||||
await processResponse(res, req, auditLog, reportedError);
|
||||
await processResponse(res, req, auditLog, error);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -48,6 +48,10 @@ describe("endpoint-validator", () => {
|
||||
isClientSideApi: true,
|
||||
isRateLimited: false,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/client/og-image")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 } => {
|
||||
// Open Graph image generation route is a client side API route but it should not be rate limited
|
||||
if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
|
||||
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(url)) return { isClientSideApi: true, isRateLimited: false };
|
||||
|
||||
const regex = /^\/api\/v\d+\/client\//;
|
||||
return { isClientSideApi: regex.test(url), isRateLimited: true };
|
||||
|
||||
@@ -7,7 +7,6 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
@@ -50,11 +49,6 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "organization_created", {
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
});
|
||||
|
||||
return newOrganization;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -51,8 +51,6 @@ checksums:
|
||||
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
|
||||
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
|
||||
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/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
|
||||
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
|
||||
@@ -123,8 +121,6 @@ checksums:
|
||||
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
|
||||
common/cancel: 2e2a849c2223911717de8caa2c71bade
|
||||
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
|
||||
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
|
||||
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
|
||||
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
|
||||
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
|
||||
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
|
||||
@@ -192,7 +188,6 @@ checksums:
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
@@ -263,7 +258,6 @@ checksums:
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
common/meta: 842eac888f134f3525f8ea613d933687
|
||||
common/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
@@ -294,9 +288,6 @@ checksums:
|
||||
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
|
||||
common/number: 2789f8391f63e7200a5521078aab017d
|
||||
common/off: 7e33a70258401abe652c9f1b595fabda
|
||||
common/offline_all_responses_synced: 9760250890a3a1901163c3f9e91602cc
|
||||
common/offline_syncing_responses: 6a518ff0248a29216b60a206e6966d4d
|
||||
common/offline_you_are_offline: c8fa81ac3e9cad1c64b963ec3f372085
|
||||
common/on: 1929bcf2fba8003c043b446a851bcb4f
|
||||
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
|
||||
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
|
||||
@@ -308,7 +299,6 @@ checksums:
|
||||
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
|
||||
common/organization_settings: 11528aa89ae9935e55dcb54478058775
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
@@ -380,6 +370,7 @@ checksums:
|
||||
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
|
||||
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
|
||||
common/size: 227fadeeff951e041ff42031a11a4626
|
||||
common/skip: b7f28dfa2f58b80b149bb82b392d0291
|
||||
common/skipped: d496f0f667e1b4364b954db71335d4ef
|
||||
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
|
||||
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
|
||||
@@ -413,7 +404,6 @@ checksums:
|
||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||
common/teams: b63448c05270497973ac4407047dae02
|
||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
||||
@@ -451,6 +441,7 @@ checksums:
|
||||
common/website_survey: 17513d25a07b6361768a15ec622b021b
|
||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
|
||||
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||
@@ -484,7 +475,7 @@ checksums:
|
||||
emails/forgot_password_email_change_password: fe6d4ba303b82f4833b3293f0c4e88c0
|
||||
emails/forgot_password_email_did_not_request: 79d35c3800e23e9d4c95bf33f250104f
|
||||
emails/forgot_password_email_heading: fe6d4ba303b82f4833b3293f0c4e88c0
|
||||
emails/forgot_password_email_link_valid_for_24_hours: 962358a7f9674f13f49278afa15d14d3
|
||||
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
|
||||
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
|
||||
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
|
||||
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||
@@ -1076,17 +1067,6 @@ checksums:
|
||||
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
|
||||
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
|
||||
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/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
|
||||
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
|
||||
@@ -1125,7 +1105,6 @@ checksums:
|
||||
environments/settings/general/only_org_owner_can_perform_action: 0244ff3c6de787935e592eac4c5e4f0b
|
||||
environments/settings/general/organization_created_successfully: 1ce874980bdd7d5de8402c276fb97a57
|
||||
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_name: 73c9b31c9032a22bd84a07881942bb04
|
||||
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
|
||||
@@ -1709,11 +1688,6 @@ checksums:
|
||||
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
|
||||
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
|
||||
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/answer_all_rows: 5ca73b038ac41922a09802fef4b5afc0
|
||||
environments/surveys/edit/validation/characters: e26d6bb531181ec1ed551e264bc86259
|
||||
@@ -2024,7 +1998,6 @@ checksums:
|
||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
|
||||
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
|
||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||
@@ -3206,3 +3179,14 @@ checksums:
|
||||
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
|
||||
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
|
||||
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
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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,13 +1,9 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
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> => {
|
||||
validateInputs([accountData, ZAccountInput]);
|
||||
|
||||
@@ -24,39 +20,3 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
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,6 +3,7 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { env } from "./env";
|
||||
|
||||
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";
|
||||
|
||||
@@ -26,10 +27,7 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
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_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
@@ -155,7 +153,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
|
||||
|
||||
export const BREVO_API_KEY = env.BREVO_API_KEY;
|
||||
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,120 +1,12 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
import { AI_PROVIDERS } from "@formbricks/types/ai";
|
||||
|
||||
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({
|
||||
export const env = createEnv({
|
||||
/*
|
||||
* Serverside Environment variables, not available on the client.
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
AI_PROVIDER: ZActiveAIProvider.optional(),
|
||||
AI_MODEL: z.string().optional(),
|
||||
AIRTABLE_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -123,14 +15,13 @@ const parsedEnv = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
|
||||
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
|
||||
E2E_TESTING: z.enum(["1", "0"]).optional(),
|
||||
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
EDGE_RATE_LIMIT_PROVIDER: z.enum(["none", "cloudflare", "cloudarmor", "envoy"]).optional(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
ENTERPRISE_LICENSE_KEY: z.string().optional(),
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
|
||||
@@ -138,21 +29,9 @@ const parsedEnv = createEnv({
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: 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_SECRET: 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(),
|
||||
HTTPS_PROXY: z.url().optional(),
|
||||
IMPRINT_URL: z
|
||||
@@ -182,13 +61,11 @@ const parsedEnv = createEnv({
|
||||
? z.string().optional()
|
||||
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
|
||||
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
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
RATE_LIMITING_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
@@ -245,7 +122,7 @@ const parsedEnv = createEnv({
|
||||
AUDIT_LOG_GET_USER_IP: z.enum(["1", "0"]).optional(),
|
||||
SESSION_MAX_AGE: z
|
||||
.string()
|
||||
.transform((val) => Number.parseInt(val, 10))
|
||||
.transform((val) => parseInt(val))
|
||||
.optional(),
|
||||
SENTRY_ENVIRONMENT: z.string().optional(),
|
||||
},
|
||||
@@ -257,8 +134,6 @@ const parsedEnv = createEnv({
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
AI_PROVIDER: process.env.AI_PROVIDER,
|
||||
AI_MODEL: process.env.AI_MODEL,
|
||||
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
|
||||
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
|
||||
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
|
||||
@@ -267,14 +142,13 @@ const parsedEnv = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
|
||||
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
|
||||
E2E_TESTING: process.env.E2E_TESTING,
|
||||
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_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,
|
||||
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
@@ -282,21 +156,9 @@ const parsedEnv = createEnv({
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
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_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
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,
|
||||
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||
IMPRINT_URL: process.env.IMPRINT_URL,
|
||||
@@ -321,10 +183,8 @@ const parsedEnv = createEnv({
|
||||
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
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,
|
||||
RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
@@ -363,7 +223,3 @@ const parsedEnv = createEnv({
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
|
||||
export const env = ZAIConfigurationEnv.superRefine(validateActiveAIProviderConfiguration)
|
||||
.transform(() => parsedEnv)
|
||||
.parse(parsedEnv);
|
||||
|
||||
@@ -84,9 +84,7 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
|
||||
|
||||
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
|
||||
if (!surveyLanguages?.length || !languageCode) return "default";
|
||||
const language = surveyLanguages.find(
|
||||
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
|
||||
);
|
||||
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export const getBillingFallbackPath = (environmentId: string, isFormbricksCloud: boolean): string => {
|
||||
const settingsPath = isFormbricksCloud ? "billing" : "enterprise";
|
||||
return `/environments/${environmentId}/settings/${settingsPath}`;
|
||||
};
|
||||
@@ -68,33 +68,6 @@ describe("Membership Service", () => {
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "server-only";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -8,67 +8,43 @@ import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
type TMembershipDbClient = PrismaClient | Prisma.TransactionClient;
|
||||
export const getMembershipByUserIdOrganizationId = reactCache(
|
||||
async (userId: string, organizationId: string): Promise<TMembership | null> => {
|
||||
validateInputs([userId, ZString], [organizationId, ZString]);
|
||||
|
||||
const getDbClient = (tx?: Prisma.TransactionClient): TMembershipDbClient => tx ?? prisma;
|
||||
|
||||
const getMembershipByUserIdOrganizationIdUncached = async (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TMembership | null> => {
|
||||
validateInputs([userId, ZString], [organizationId, ZString]);
|
||||
|
||||
try {
|
||||
const membership = await getDbClient(tx).membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
try {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!membership) return null;
|
||||
if (!membership) return null;
|
||||
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting membership by user id and organization id");
|
||||
throw new DatabaseError(error.message);
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting membership by user id and organization id");
|
||||
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 (
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
data: Partial<TMembership>,
|
||||
tx?: Prisma.TransactionClient
|
||||
data: Partial<TMembership>
|
||||
): Promise<TMembership> => {
|
||||
validateInputs([organizationId, ZString], [userId, ZString], [data, ZMembership.partial()]);
|
||||
|
||||
try {
|
||||
const prismaClient = getDbClient(tx);
|
||||
const existingMembership = await prismaClient.membership.findUnique({
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
@@ -83,7 +59,7 @@ export const createMembership = async (
|
||||
|
||||
let membership: TMembership;
|
||||
if (!existingMembership) {
|
||||
membership = await prismaClient.membership.create({
|
||||
membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
organizationId,
|
||||
@@ -92,7 +68,7 @@ export const createMembership = async (
|
||||
},
|
||||
});
|
||||
} else {
|
||||
membership = await prismaClient.membership.update({
|
||||
membership = await prisma.membership.update({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
|
||||
@@ -37,8 +37,7 @@ describe("auth", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
|
||||
|
||||
@@ -72,8 +72,7 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -125,8 +124,7 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
},
|
||||
];
|
||||
@@ -178,8 +176,7 @@ describe("Organization Service", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: expectedBilling,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -238,8 +235,7 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user1" }, { userId: "user2" }],
|
||||
projects: [
|
||||
@@ -280,8 +276,7 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: expect.any(Date),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
});
|
||||
expect(prisma.organization.update).toHaveBeenCalledWith({
|
||||
|
||||
@@ -34,8 +34,7 @@ export const select = {
|
||||
stripe: true,
|
||||
},
|
||||
},
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
isAIEnabled: true,
|
||||
whitelabel: true,
|
||||
} satisfies Prisma.OrganizationSelect;
|
||||
|
||||
@@ -73,8 +72,7 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
|
||||
updatedAt: organization.updatedAt,
|
||||
name: organization.name,
|
||||
billing: mapOrganizationBilling(organization.billing),
|
||||
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
|
||||
});
|
||||
|
||||
@@ -367,34 +365,35 @@ export const subscribeOrganizationMembersToSurveyResponses = async (
|
||||
createdBy: string,
|
||||
organizationId: string
|
||||
): Promise<void> => {
|
||||
const surveyCreator = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: createdBy,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const surveyCreator = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
if (!surveyCreator) {
|
||||
throw new ResourceNotFoundError("User", createdBy);
|
||||
if (!surveyCreator) {
|
||||
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(
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { posthogServerClient } from "./server";
|
||||
|
||||
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
export function capturePostHogEvent(
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties?: PostHogEventProperties
|
||||
): void {
|
||||
if (!posthogServerClient) return;
|
||||
|
||||
try {
|
||||
posthogServerClient.capture({
|
||||
distinctId,
|
||||
event: eventName,
|
||||
properties: {
|
||||
...properties,
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, eventName }, "Failed to capture PostHog event");
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { capturePostHogEvent } from "./capture";
|
||||
@@ -1,171 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
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,11 +81,7 @@ export const extractChoiceIdsFromResponse = (
|
||||
|
||||
if (Array.isArray(responseValue)) {
|
||||
// Multiple choice case - response is an array of selected choice labels
|
||||
// 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);
|
||||
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
|
||||
} else if (typeof responseValue === "string") {
|
||||
// Single choice case - response is a single choice label
|
||||
const choiceId = findChoiceByLabel(responseValue);
|
||||
|
||||
@@ -232,8 +232,7 @@ export const mockOrganizationOutput: TOrganization = {
|
||||
name: "mock Organization",
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
|
||||
@@ -70,8 +70,7 @@ describe("User Service", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "org2",
|
||||
@@ -88,8 +87,7 @@ describe("User Service", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAIEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
EXPECTED_ERROR_NAMES,
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidInputError,
|
||||
InvalidPasswordResetTokenError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
@@ -73,7 +71,6 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"AuthenticationError",
|
||||
"OperationNotAllowedError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -90,7 +87,6 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
@@ -178,14 +174,6 @@ describe("actionClient handleServerError", () => {
|
||||
expect(result?.serverError).toBe("Not allowed");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("InvalidPasswordResetTokenError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE)
|
||||
);
|
||||
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unexpected errors SHOULD be reported to Sentry", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
|
||||
import { getValidatedCallbackUrl, isStringUrl, testURLmatch } from "./url";
|
||||
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -72,25 +72,23 @@ describe("testURLmatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValidatedCallbackUrl", () => {
|
||||
describe("isValidCallbackUrl", () => {
|
||||
const WEBAPP_URL = "https://webapp.example.com";
|
||||
|
||||
test("returns the normalized callback URL for a valid callback", () => {
|
||||
expect(getValidatedCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(
|
||||
"https://webapp.example.com/callback"
|
||||
);
|
||||
test("returns true for valid callback URL", () => {
|
||||
expect(isValidCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns null for invalid scheme", () => {
|
||||
expect(getValidatedCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBeNull();
|
||||
test("returns false for invalid scheme", () => {
|
||||
expect(isValidCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns null for invalid domain", () => {
|
||||
expect(getValidatedCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBeNull();
|
||||
test("returns false for invalid domain", () => {
|
||||
expect(isValidCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns null for malformed URL", () => {
|
||||
expect(getValidatedCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBeNull();
|
||||
test("returns false for malformed URL", () => {
|
||||
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,43 +35,18 @@ export const testURLmatch = (
|
||||
};
|
||||
|
||||
// Helper function to validate callback URLs
|
||||
export const getValidatedCallbackUrl = (
|
||||
url: string | null | undefined,
|
||||
WEBAPP_URL: string
|
||||
): string | null => {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean => {
|
||||
try {
|
||||
const parsedWebAppUrl = new URL(WEBAPP_URL);
|
||||
const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url);
|
||||
const isRootRelativePath = url.startsWith("/");
|
||||
|
||||
// Reject ambiguous non-URL values like "foo" while still allowing safe root-relative paths.
|
||||
if (!isAbsoluteUrl && !isRootRelativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = isAbsoluteUrl ? new URL(url) : new URL(url, parsedWebAppUrl.origin);
|
||||
const parsedUrl = new URL(url);
|
||||
const allowedSchemes = ["https:", "http:"];
|
||||
const allowedOrigins = new Set([parsedWebAppUrl.origin]);
|
||||
|
||||
if (!allowedSchemes.includes(parsedUrl.protocol)) {
|
||||
return null;
|
||||
}
|
||||
// Extract the domain from WEBAPP_URL
|
||||
const parsedWebAppUrl = new URL(WEBAPP_URL);
|
||||
const allowedDomains = [parsedWebAppUrl.hostname];
|
||||
|
||||
if (!allowedOrigins.has(parsedUrl.origin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsedUrl.username || parsedUrl.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedUrl.toString();
|
||||
} catch {
|
||||
return null;
|
||||
return allowedSchemes.includes(parsedUrl.protocol) && allowedDomains.includes(parsedUrl.hostname);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ vi.mock("node:dns", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
|
||||
}));
|
||||
|
||||
const mockResolve = vi.mocked(dns.resolve);
|
||||
const mockResolve6 = vi.mocked(dns.resolve6);
|
||||
|
||||
@@ -298,78 +294,4 @@ describe("validateWebhookUrl", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
|
||||
test("allows private IP URLs when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows localhost when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows localhost.localdomain when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows hostname resolving to private IP when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
setupDnsResolution(["192.168.1.1"]);
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("still rejects unresolvable hostnames when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
setupDnsResolution(null, null);
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
|
||||
"Could not resolve webhook URL hostname"
|
||||
);
|
||||
});
|
||||
|
||||
test("still rejects invalid URL format when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
|
||||
});
|
||||
|
||||
test("still rejects non-HTTP protocols when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
|
||||
"Webhook URL must use HTTPS or HTTP protocol"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "server-only";
|
||||
import dns from "node:dns";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
@@ -140,10 +139,8 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
|
||||
// Direct IP literal — validate without DNS resolution
|
||||
@@ -152,17 +149,12 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
|
||||
if (isIPv4Literal || isIPv6Literal) {
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
|
||||
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Domain name — resolve DNS and validate every resolved IP
|
||||
let resolvedIPs: string[];
|
||||
try {
|
||||
@@ -176,11 +168,9 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,8 +63,6 @@
|
||||
"login_with_email": "Mit E-Mail einloggen",
|
||||
"lost_access": "Zugang verloren?",
|
||||
"new_to_formbricks": "Neu bei Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Einen Backup-Code verwenden"
|
||||
},
|
||||
"saml_connection_error": "Etwas ist schiefgelaufen. Bitte überprüfe die App-Konsole für weitere Details.",
|
||||
@@ -150,8 +148,6 @@
|
||||
"bottom_right": "Unten rechts",
|
||||
"cancel": "Abbrechen",
|
||||
"centered_modal": "Zentriertes Modalfenster",
|
||||
"change_organization": "Organisation wechseln",
|
||||
"change_workspace": "Workspace wechseln",
|
||||
"choices": "Entscheidungen",
|
||||
"choose_environment": "Umgebung auswählen",
|
||||
"choose_organization": "Organisation auswählen",
|
||||
@@ -219,7 +215,6 @@
|
||||
"duplicate_copy_number": "(Kopie {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"elements": "Elemente",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
@@ -290,7 +285,6 @@
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership": "Mitgliedschaft",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadaten",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
@@ -321,9 +315,6 @@
|
||||
"notifications": "Benachrichtigungen",
|
||||
"number": "Nummer",
|
||||
"off": "Aus",
|
||||
"offline_all_responses_synced": "Deine Antwort wurde erfolgreich gespeichert.",
|
||||
"offline_syncing_responses": "Deine Antworten werden synchronisiert…",
|
||||
"offline_you_are_offline": "Du bist offline. Deine Antwort ist in deinem Browser gespeichert und wird gesichert, sobald du wieder online bist.",
|
||||
"on": "An",
|
||||
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
@@ -335,7 +326,6 @@
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"other": "Andere",
|
||||
"other_filters": "Weitere Filter",
|
||||
"others": "Andere",
|
||||
"overlay_color": "Overlay-Farbe",
|
||||
"overview": "Überblick",
|
||||
@@ -407,6 +397,7 @@
|
||||
"show_response_count": "Antwortanzahl anzeigen",
|
||||
"shown": "Angezeigt",
|
||||
"size": "Größe",
|
||||
"skip": "Überspringen",
|
||||
"skipped": "Übersprungen",
|
||||
"skips": "Übersprungen",
|
||||
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
|
||||
@@ -440,7 +431,6 @@
|
||||
"team_name": "Teamname",
|
||||
"team_role": "Team-Rolle",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
"time_to_finish": "Zeit zum Fertigstellen",
|
||||
@@ -478,6 +468,7 @@
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weeks": "Wochen",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Arbeitsbereich",
|
||||
"workspace_configuration": "Projektkonfiguration",
|
||||
"workspace_created_successfully": "Projekt erfolgreich erstellt",
|
||||
@@ -513,7 +504,7 @@
|
||||
"forgot_password_email_change_password": "Passwort ändern",
|
||||
"forgot_password_email_did_not_request": "Wenn Du sie nicht angefordert hast, ignoriere bitte diese E-Mail.",
|
||||
"forgot_password_email_heading": "Passwort ändern",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Der Link ist {minutes} Minuten gültig.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
|
||||
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
|
||||
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
@@ -1137,17 +1128,6 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "KI-Datenanalyse und -Anreicherung sind für diese Organisation deaktiviert.",
|
||||
"ai_data_analysis_enabled": "Datenanreicherung & Analyse (KI)",
|
||||
"ai_data_analysis_enabled_description": "KI, um mehr aus deinen Daten herauszuholen, Dashboards, Diagramme, Berichte und mehr einzurichten. Greift auf deine Erfahrungsdaten zu.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
|
||||
"ai_features_not_enabled_for_organization": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
|
||||
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte den Administrator, AI_PROVIDER, die Anmeldedaten für diesen Anbieter und die passende Modellliste zu konfigurieren, bevor du KI-Funktionen aktivierst.",
|
||||
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
|
||||
"ai_smart_tools_disabled_for_organization": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
|
||||
"ai_smart_tools_enabled": "Intelligente Funktionen (KI)",
|
||||
"ai_smart_tools_enabled_description": "KI, um dir zu helfen, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
|
||||
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
|
||||
"cannot_delete_only_organization": "Das ist deine einzige Organisation, sie kann nicht gelöscht werden. Erstelle zuerst eine neue Organisation.",
|
||||
"cannot_leave_only_organization": "Du kannst diese Organisation nicht verlassen, da es deine einzige Organisation ist. Erstelle zuerst eine neue Organisation.",
|
||||
@@ -1186,7 +1166,6 @@
|
||||
"only_org_owner_can_perform_action": "Nur der Besitzer kann die Organisation löschen.",
|
||||
"organization_created_successfully": "Organisation erfolgreich erstellt!",
|
||||
"organization_deleted_successfully": "Organisation erfolgreich gelöscht.",
|
||||
"organization_deletion_disabled": "Das Löschen von Organisationen ist deaktiviert.",
|
||||
"organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!",
|
||||
"organization_name": "Organisationsname",
|
||||
"organization_name_description": "Gib deiner Organisation einen Namen.",
|
||||
@@ -1782,11 +1761,6 @@
|
||||
"upper_label": "Oberes Label",
|
||||
"url_filters": "URL-Filter",
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"validate_id_duplicate": "{type}-ID existiert bereits in Fragen, versteckten Feldern oder Variablen.",
|
||||
"validate_id_empty": "Bitte gib eine {type}-ID ein.",
|
||||
"validate_id_invalid_chars": "{type}-ID ist nicht erlaubt. Bitte verwende nur alphanumerische Zeichen, Bindestriche oder Unterstriche.",
|
||||
"validate_id_no_spaces": "{type}-ID darf keine Leerzeichen enthalten. Bitte entferne die Leerzeichen.",
|
||||
"validate_id_reserved": "{type}-ID \"{field}\" ist nicht erlaubt. Es handelt sich um ein reserviertes Schlüsselwort.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Validierungsregel hinzufügen",
|
||||
"answer_all_rows": "Alle Zeilen beantworten",
|
||||
@@ -2127,7 +2101,6 @@
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
@@ -2638,7 +2611,7 @@
|
||||
"csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?",
|
||||
"csat_question_1_lower_label": "Nicht wahrscheinlich",
|
||||
"csat_question_1_upper_label": "Sehr wahrscheinlich",
|
||||
"csat_question_2_choice_1": "Eher zufrieden",
|
||||
"csat_question_2_choice_1": "Etwas zufrieden",
|
||||
"csat_question_2_choice_2": "Sehr zufrieden",
|
||||
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
|
||||
"csat_question_2_choice_4": "Etwas unzufrieden",
|
||||
@@ -3361,5 +3334,18 @@
|
||||
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
|
||||
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
|
||||
"usability_score_name": "System Usability Score Survey (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
|
||||
"coming_soon_title": "Wir sind fast da!",
|
||||
"follow_up_label": "Möchten Sie noch etwas hinzufügen?",
|
||||
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
|
||||
"generate_button": "Workflow generieren",
|
||||
"heading": "Welchen Workflow möchtest du erstellen?",
|
||||
"placeholder": "Beschreiben Sie den Workflow, den Sie erstellen möchten…",
|
||||
"subheading": "Generiere deinen Workflow in Sekunden.",
|
||||
"submit_button": "Details hinzufügen",
|
||||
"thank_you_description": "Ihr Feedback hilft uns, die Workflows-Funktion so zu gestalten, wie Sie sie brauchen. Wir halten Sie über unseren Fortschritt auf dem Laufenden.",
|
||||
"thank_you_title": "Danke für dein Feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,6 @@
|
||||
"login_with_email": "Login with Email",
|
||||
"lost_access": "Lost Access?",
|
||||
"new_to_formbricks": "New to Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Use a backup code"
|
||||
},
|
||||
"saml_connection_error": "Something went wrong. Please check your app console for more details.",
|
||||
@@ -150,8 +148,6 @@
|
||||
"bottom_right": "Bottom Right",
|
||||
"cancel": "Cancel",
|
||||
"centered_modal": "Centered Modal",
|
||||
"change_organization": "Change organization",
|
||||
"change_workspace": "Change workspace",
|
||||
"choices": "Choices",
|
||||
"choose_environment": "Choose environment",
|
||||
"choose_organization": "Choose organization",
|
||||
@@ -219,7 +215,6 @@
|
||||
"duplicate_copy_number": "(copy {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"elements": "Elements",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
@@ -290,7 +285,6 @@
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership": "Membership",
|
||||
"membership_not_found": "Membership not found",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
@@ -321,9 +315,6 @@
|
||||
"notifications": "Notifications",
|
||||
"number": "Number",
|
||||
"off": "Off",
|
||||
"offline_all_responses_synced": "Your response was saved successfully.",
|
||||
"offline_syncing_responses": "Syncing your responses…",
|
||||
"offline_you_are_offline": "You're offline. Your response is stored in your browser and will be saved when you're back online.",
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
@@ -335,7 +326,6 @@
|
||||
"organization_id": "Organization ID",
|
||||
"organization_settings": "Organization settings",
|
||||
"other": "Other",
|
||||
"other_filters": "Other Filters",
|
||||
"others": "Others",
|
||||
"overlay_color": "Overlay color",
|
||||
"overview": "Overview",
|
||||
@@ -407,6 +397,7 @@
|
||||
"show_response_count": "Show response count",
|
||||
"shown": "Shown",
|
||||
"size": "Size",
|
||||
"skip": "Skip",
|
||||
"skipped": "Skipped",
|
||||
"skips": "Skips",
|
||||
"some_files_failed_to_upload": "Some files failed to upload",
|
||||
@@ -440,7 +431,6 @@
|
||||
"team_name": "Team name",
|
||||
"team_role": "Team role",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"text": "Text",
|
||||
"time": "Time",
|
||||
"time_to_finish": "Time to finish",
|
||||
@@ -478,6 +468,7 @@
|
||||
"website_survey": "Website Survey",
|
||||
"weeks": "weeks",
|
||||
"welcome_card": "Welcome card",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Workspace",
|
||||
"workspace_configuration": "Workspace Configuration",
|
||||
"workspace_created_successfully": "Workspace created successfully",
|
||||
@@ -513,7 +504,7 @@
|
||||
"forgot_password_email_change_password": "Change password",
|
||||
"forgot_password_email_did_not_request": "If you did not request this, please ignore this email.",
|
||||
"forgot_password_email_heading": "Change password",
|
||||
"forgot_password_email_link_valid_for_24_hours": "The link is valid for {minutes} minutes.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
|
||||
"forgot_password_email_subject": "Reset your Formbricks password",
|
||||
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
|
||||
"hidden_field": "Hidden field",
|
||||
@@ -1137,17 +1128,6 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "AI data analysis is disabled for this organization.",
|
||||
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Manage AI-powered features for this organization.",
|
||||
"ai_features_not_enabled_for_organization": "AI features are not enabled for this organization.",
|
||||
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
|
||||
"ai_settings_updated_successfully": "AI settings updated successfully",
|
||||
"ai_smart_tools_disabled_for_organization": "AI smart tools are disabled for this organization.",
|
||||
"ai_smart_tools_enabled": "Smart functionality (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
|
||||
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
|
||||
"cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.",
|
||||
"cannot_leave_only_organization": "You cannot leave this organization as it is your only organization. Create a new organization first.",
|
||||
@@ -1186,7 +1166,6 @@
|
||||
"only_org_owner_can_perform_action": "Only organization owners can access this setting.",
|
||||
"organization_created_successfully": "Organization created successfully",
|
||||
"organization_deleted_successfully": "Organization deleted successfully",
|
||||
"organization_deletion_disabled": "Organization deletion is disabled.",
|
||||
"organization_invite_link_ready": "Your organization invite link is ready!",
|
||||
"organization_name": "Organization Name",
|
||||
"organization_name_description": "Give your organization a descriptive name.",
|
||||
@@ -1782,11 +1761,6 @@
|
||||
"upper_label": "Upper Label",
|
||||
"url_filters": "URL Filters",
|
||||
"url_not_supported": "URL not supported",
|
||||
"validate_id_duplicate": "{type} ID already exists in questions, hidden fields, or variables.",
|
||||
"validate_id_empty": "Please enter a {type} ID.",
|
||||
"validate_id_invalid_chars": "{type} ID is not allowed. Please use only alphanumeric characters, hyphens, or underscores.",
|
||||
"validate_id_no_spaces": "{type} ID cannot contain spaces. Please remove spaces.",
|
||||
"validate_id_reserved": "{type} ID \"{field}\" is not allowed. It is a reserved keyword.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Add validation rule",
|
||||
"answer_all_rows": "Answer all rows",
|
||||
@@ -2127,7 +2101,6 @@
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"ttc_survey_tooltip": "Average time to complete the survey.",
|
||||
"ttc_tooltip": "Average time to complete the question.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"use_personal_links": "Use personal links",
|
||||
@@ -3361,5 +3334,18 @@
|
||||
"usability_question_9_headline": "I felt confident while using the system.",
|
||||
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
|
||||
"usability_score_name": "System Usability Score (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
|
||||
"coming_soon_title": "We are almost there!",
|
||||
"follow_up_label": "Is there anything else you would like to add?",
|
||||
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
|
||||
"generate_button": "Generate workflow",
|
||||
"heading": "What workflow do you want to create?",
|
||||
"placeholder": "Describe the workflow you want to generate…",
|
||||
"subheading": "Generate your workflow in seconds.",
|
||||
"submit_button": "Add details",
|
||||
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We will keep you posted on our progress.",
|
||||
"thank_you_title": "Thank you for your feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user