Compare commits

..

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe a67aa1b3ce chore: add envoy rate-limit hardening tooling 2026-04-01 14:17:04 +05:30
273 changed files with 3001 additions and 13154 deletions
-9
View File
@@ -1,9 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''
-28
View File
@@ -138,31 +138,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,9 +191,6 @@ 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.
+1 -13
View File
@@ -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
+25 -1
View File
@@ -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.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development
### 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,41 @@
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Cog,
FoldersIcon,
Loader2,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
} 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 +48,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 +59,6 @@ export const MainNavigation = ({
isFormbricksCloud,
isDevelopment,
publicDomain,
isMultiOrgEnabled,
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -113,12 +68,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 +105,6 @@ export const MainNavigation = ({
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/environments/${environment.id}/contacts`,
@@ -165,17 +114,15 @@ export const MainNavigation = ({
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
{
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]
);
const dropdownNavigation = [
@@ -198,183 +145,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 +174,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 +214,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 +255,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 +295,8 @@ export const MainNavigation = ({
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link
href={link.href}
@@ -782,6 +310,7 @@ export const MainNavigation = ({
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
@@ -804,28 +333,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`);
@@ -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,18 +2,15 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
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,
@@ -69,53 +66,10 @@ export const updateOrganizationNameAction = authenticatedActionClient
)
);
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"));
}
};
const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
});
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
@@ -129,33 +83,17 @@ export const updateOrganizationAISettingsAction = authenticatedActionClient
}: {
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({
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationAISettingsInput,
schema: ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
}),
data: parsedInput.data,
roles: ["owner", "manager"],
});
}
})
)
);
@@ -168,10 +106,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,
@@ -7,7 +7,6 @@ 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";
@@ -16,93 +15,60 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
}: Readonly<AISettingsToggleProps>) => {
export const AISettingsToggle = ({ organization, membershipRole }: 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,
data: { [field]: checked },
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
toast.error(getFormattedErrorMessage(response));
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
} catch {
toast.error(t("common.something_went_wrong"));
} finally {
setLoadingField(null);
}
};
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
<Alert variant="warning">
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
</Alert>
)}
<div>
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
isChecked={organization.isAISmartToolsEnabled}
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}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
isChecked={organization.isAIDataAnalysisEnabled}
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}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
@@ -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";
@@ -65,11 +64,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
/>
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
</SettingsCard>
<EmailCustomizationSettings
organization={organization}
@@ -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({
@@ -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"
);
}
+1 -14
View File
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { 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,18 +1,9 @@
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);
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
@@ -63,7 +54,7 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123"): Promise<WrappedAuthOptions> => {
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
@@ -72,17 +63,7 @@ const getWrappedAuthOptions = async (requestId: string = "req-123"): Promise<Wra
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;
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
@@ -155,34 +136,4 @@ describe("auth route audit logging", () => {
})
);
});
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",
}),
})
);
});
});
@@ -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
@@ -309,38 +303,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`
@@ -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,11 +1,8 @@
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 { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -16,29 +13,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(
{},
@@ -51,40 +25,38 @@ 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);
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
if ("response" in validatedInput) {
return validatedInput.response;
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { displayInputData } = validatedInput;
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";
@@ -25,86 +25,78 @@ 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> => {
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 getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
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 +113,7 @@ const validateResponseSubmission = async (
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
@@ -128,29 +121,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 +139,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]",
},
}),
})
);
});
});
-282
View File
@@ -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 };
};
+37 -112
View File
@@ -6,6 +6,7 @@ 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(),
@@ -13,13 +14,24 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: 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(() => ({
@@ -74,6 +86,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 {
@@ -109,6 +122,12 @@ 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 () => {
@@ -136,7 +155,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,33 +177,9 @@ 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 () => {
@@ -211,7 +206,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);
@@ -256,7 +251,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,78 +280,8 @@ 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 () => {
@@ -383,7 +308,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);
@@ -433,7 +358,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);
@@ -453,7 +378,7 @@ describe("withV1ApiWrapper", () => {
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
@@ -487,7 +412,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);
@@ -546,7 +471,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);
@@ -574,7 +499,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 +518,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 +530,7 @@ describe("buildAuditLogBaseObject", () => {
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl: V1_MANAGEMENT_SURVEYS_URL,
apiUrl: "https://api.test/v1/management/surveys",
});
});
});
+35 -17
View File
@@ -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,7 +11,7 @@ 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 { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -33,10 +33,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;
@@ -96,7 +93,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 +158,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 +195,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 +210,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 +267,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;
@@ -293,10 +312,9 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
// === 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;
};
@@ -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;
})
);
+3 -22
View File
@@ -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
@@ -294,9 +290,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
@@ -413,7 +406,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
@@ -484,7 +476,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,15 +1068,11 @@ 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
@@ -1125,7 +1113,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 +1696,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 +2006,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
@@ -2483,8 +2464,8 @@ checksums:
templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90
templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f
templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a
templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
templates/csat_question_2_choice_1: 0cb1260dd25e94f56c2da7ab21b0e0ae
templates/csat_question_2_choice_2: f12ed9d98c7965ab949efcc25f8ca85e
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab
+3 -10
View File
@@ -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]);
@@ -25,10 +21,7 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
}
};
export const upsertAccount = async (
accountData: TAccountInput,
tx?: Prisma.TransactionClient
): Promise<TAccount> => {
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
@@ -40,7 +33,7 @@ export const upsertAccount = async (
};
try {
const account = await getDbClient(tx).account.upsert({
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
-184
View File
@@ -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"
);
});
});
-104
View File
@@ -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;
}
};
-54
View File
@@ -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);
});
});
-31
View File
@@ -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,
};
};
-1
View File
@@ -155,7 +155,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;
+2 -142
View File
@@ -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(),
@@ -138,21 +30,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
@@ -188,7 +68,6 @@ const parsedEnv = createEnv({
.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 +124,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 +136,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,
@@ -282,21 +159,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,
@@ -324,7 +189,6 @@ const parsedEnv = createEnv({
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 +227,3 @@ const parsedEnv = createEnv({
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
},
});
export const env = ZAIConfigurationEnv.superRefine(validateActiveAIProviderConfiguration)
.transform(() => parsedEnv)
.parse(parsedEnv);
@@ -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");
});
});
-4
View File
@@ -1,4 +0,0 @@
export const getBillingFallbackPath = (environmentId: string, isFormbricksCloud: boolean): string => {
const settingsPath = isFormbricksCloud ? "billing" : "enterprise";
return `/environments/${environmentId}/settings/${settingsPath}`;
};
-27
View File
@@ -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", () => {
+25 -49
View File
@@ -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,
+27 -26
View File
@@ -367,34 +367,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(
-84
View File
@@ -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();
});
});
-27
View File
@@ -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
View File
@@ -1 +0,0 @@
export { capturePostHogEvent } from "./capture";
-171
View File
@@ -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();
});
});
-43
View File
@@ -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;
}
+1 -5
View File
@@ -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);
+10 -12
View File
@@ -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);
});
});
+8 -33
View File
@@ -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;
}
};
+2 -21
View File
@@ -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",
@@ -321,9 +317,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.",
@@ -440,7 +433,6 @@
"team_name": "Teamname",
"team_role": "Team-Rolle",
"teams": "Teams",
"terms_of_service": "Nutzungsbedingungen",
"text": "Text",
"time": "Zeit",
"time_to_finish": "Zeit zum Fertigstellen",
@@ -1137,15 +1129,11 @@
"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.",
@@ -1186,7 +1174,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 +1769,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 +2109,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,8 +2619,8 @@
"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_2": "Sehr zufrieden",
"csat_question_2_choice_1": "Sehr zufrieden",
"csat_question_2_choice_2": "Etwas zufrieden",
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
"csat_question_2_choice_4": "Etwas unzufrieden",
"csat_question_2_choice_5": "Sehr unzufrieden",
+2 -21
View File
@@ -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",
@@ -321,9 +317,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.",
@@ -440,7 +433,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",
@@ -1137,15 +1129,11 @@
"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.",
@@ -1186,7 +1174,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 +1769,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 +2109,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",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "How likely is it that you would recommend this $[projectName] to a friend or colleague?",
"csat_question_1_lower_label": "Not likely",
"csat_question_1_upper_label": "Very likely",
"csat_question_2_choice_1": "Somewhat satisfied",
"csat_question_2_choice_2": "Very satisfied",
"csat_question_2_choice_1": "Very satisfied",
"csat_question_2_choice_2": "Somewhat satisfied",
"csat_question_2_choice_3": "Neither satisfied nor dissatisfied",
"csat_question_2_choice_4": "Somewhat dissatisfied",
"csat_question_2_choice_5": "Very dissatisfied",
+2 -21
View File
@@ -63,8 +63,6 @@
"login_with_email": "Iniciar sesión con correo electrónico",
"lost_access": "¿Has perdido el acceso?",
"new_to_formbricks": "¿Nuevo en 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": "Usar un código de respaldo"
},
"saml_connection_error": "Algo salió mal. Por favor, consulta la consola de tu aplicación para más detalles.",
@@ -150,8 +148,6 @@
"bottom_right": "Inferior derecha",
"cancel": "Cancelar",
"centered_modal": "Modal centrado",
"change_organization": "Cambiar organización",
"change_workspace": "Cambiar espacio de trabajo",
"choices": "Opciones",
"choose_environment": "Elegir entorno",
"choose_organization": "Elegir organización",
@@ -321,9 +317,6 @@
"notifications": "Notificaciones",
"number": "Número",
"off": "Desactivado",
"offline_all_responses_synced": "Tu respuesta se guardó correctamente.",
"offline_syncing_responses": "Sincronizando tus respuestas…",
"offline_you_are_offline": "Estás sin conexión. Tu respuesta está almacenada en tu navegador y se guardará cuando vuelvas a estar en línea.",
"on": "Activado",
"only_one_file_allowed": "Solo se permite un archivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
@@ -440,7 +433,6 @@
"team_name": "Nombre del equipo",
"team_role": "Rol del equipo",
"teams": "Equipos",
"terms_of_service": "Términos de servicio",
"text": "Texto",
"time": "Hora",
"time_to_finish": "Tiempo para finalizar",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
},
"general": {
"ai_data_analysis_disabled_for_organization": "El análisis y enriquecimiento de datos con IA está deshabilitado para esta organización.",
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_features_not_enabled_for_organization": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
"ai_smart_tools_disabled_for_organization": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Solo los propietarios de la organización pueden acceder a esta configuración.",
"organization_created_successfully": "¡Organización creada correctamente!",
"organization_deleted_successfully": "Organización eliminada correctamente.",
"organization_deletion_disabled": "La eliminación de organizaciones está deshabilitada.",
"organization_invite_link_ready": "¡Tu enlace de invitación a la organización está listo!",
"organization_name": "Nombre de la organización",
"organization_name_description": "Dale a tu organización un nombre descriptivo.",
@@ -1782,11 +1769,6 @@
"upper_label": "Etiqueta superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"validate_id_duplicate": "El ID de {type} ya existe en preguntas, campos ocultos o variables.",
"validate_id_empty": "Por favor, introduce un ID de {type}.",
"validate_id_invalid_chars": "El ID de {type} no está permitido. Por favor, utiliza solo caracteres alfanuméricos, guiones o guiones bajos.",
"validate_id_no_spaces": "El ID de {type} no puede contener espacios. Por favor, elimina los espacios.",
"validate_id_reserved": "El ID de {type} \"{field}\" no está permitido. Es una palabra reservada.",
"validation": {
"add_validation_rule": "Añadir regla de validación",
"answer_all_rows": "Responde todas las filas",
@@ -2127,7 +2109,6 @@
"this_quarter": "Este trimestre",
"this_year": "Este año",
"time_to_complete": "Tiempo para completar",
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
"unknown_question_type": "Tipo de pregunta desconocido",
"use_personal_links": "Usar enlaces personales",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "¿Qué probabilidad hay de que recomiendes este $[projectName] a un amigo o colega?",
"csat_question_1_lower_label": "Poco probable",
"csat_question_1_upper_label": "Muy probable",
"csat_question_2_choice_1": "Algo satisfecho",
"csat_question_2_choice_2": "Muy satisfecho",
"csat_question_2_choice_1": "Muy satisfecho",
"csat_question_2_choice_2": "Algo satisfecho",
"csat_question_2_choice_3": "Ni satisfecho ni insatisfecho",
"csat_question_2_choice_4": "Algo insatisfecho",
"csat_question_2_choice_5": "Muy insatisfecho",
+3 -22
View File
@@ -63,8 +63,6 @@
"login_with_email": "Se connecter avec un e-mail",
"lost_access": "Accès perdu ?",
"new_to_formbricks": "Nouveau sur 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": "Utiliser un code de secours"
},
"saml_connection_error": "Quelque chose s'est mal passé. Veuillez vérifier la console de votre application pour plus de détails.",
@@ -150,8 +148,6 @@
"bottom_right": "En bas à droite",
"cancel": "Annuler",
"centered_modal": "Au centre",
"change_organization": "Changer d'organisation",
"change_workspace": "Changer d'espace de travail",
"choices": "Choix",
"choose_environment": "Choisir l'environnement",
"choose_organization": "Choisir l'organisation",
@@ -321,9 +317,6 @@
"notifications": "Notifications",
"number": "Numéro",
"off": "Éteint",
"offline_all_responses_synced": "Ta réponse a été enregistrée avec succès.",
"offline_syncing_responses": "Synchronisation de vos réponses…",
"offline_you_are_offline": "Tu es hors ligne. Ta réponse est stockée dans ton navigateur et sera enregistrée dès que tu seras de nouveau en ligne.",
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
@@ -440,7 +433,6 @@
"team_name": "Nom de l'équipe",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"terms_of_service": "Conditions d'utilisation",
"text": "Texte",
"time": "Temps",
"time_to_finish": "Temps de finir",
@@ -513,7 +505,7 @@
"forgot_password_email_change_password": "Changer le mot de passe",
"forgot_password_email_did_not_request": "Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
"forgot_password_email_heading": "Changer le mot de passe",
"forgot_password_email_link_valid_for_24_hours": "Le lien est valide pendant {minutes} minutes.",
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant {minutes} minutes.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours."
},
"general": {
"ai_data_analysis_disabled_for_organization": "L'analyse et l'enrichissement des données par IA sont désactivés pour cette organisation.",
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
"ai_enabled": "IA Formbricks",
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
"ai_features_not_enabled_for_organization": "Les fonctionnalités d'IA ne sont pas activées pour cette organisation.",
"ai_instance_not_configured": "L'IA est configurée au niveau de l'instance via des variables d'environnement. Demandez à votre administrateur de définir AI_PROVIDER, les identifiants du fournisseur et la liste de modèles correspondante avant d'activer les fonctionnalités d'IA.",
"ai_settings_updated_successfully": "Paramètres IA mis à jour avec succès",
"ai_smart_tools_disabled_for_organization": "Les outils intelligents d'IA sont désactivés pour cette organisation.",
"ai_smart_tools_enabled": "Fonctionnalités intelligentes (IA)",
"ai_smart_tools_enabled_description": "L'IA pour vous aider à accomplir plus en moins de temps. N'accède jamais aux données collectées avec Formbricks. Utilisée uniquement pour, par exemple, traduire les sondages dans d'autres langues.",
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Seules les personnes ayant un rôle d'administrateur dans l'organisation peuvent accéder à ce paramètre.",
"organization_created_successfully": "Organisation créée avec succès !",
"organization_deleted_successfully": "Organisation supprimée avec succès.",
"organization_deletion_disabled": "La suppression des organisations est désactivée.",
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
"organization_name": "Nom de l'organisation",
"organization_name_description": "Attribuez un nom descriptif à votre organisation.",
@@ -1782,11 +1769,6 @@
"upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"validate_id_duplicate": "L'ID {type} existe déjà dans les questions, champs masqués ou variables.",
"validate_id_empty": "Veuillez saisir un ID {type}.",
"validate_id_invalid_chars": "L'ID {type} n'est pas autorisé. Veuillez utiliser uniquement des caractères alphanumériques, des traits d'union ou des underscores.",
"validate_id_no_spaces": "L'ID {type} ne peut pas contenir d'espaces. Veuillez supprimer les espaces.",
"validate_id_reserved": "L'ID {type} \"{field}\" n'est pas autorisé. C'est un mot-clé réservé.",
"validation": {
"add_validation_rule": "Ajouter une règle de validation",
"answer_all_rows": "Répondre à toutes les lignes",
@@ -2127,7 +2109,6 @@
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
"time_to_complete": "Temps à compléter",
"ttc_survey_tooltip": "Temps moyen pour compléter le sondage.",
"ttc_tooltip": "Temps moyen pour compléter la question.",
"unknown_question_type": "Type de question inconnu",
"use_personal_links": "Utilisez des liens personnels",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Quelle est la probabilité que vous recommandiez ce $[projectName] à un ami ou un collègue ?",
"csat_question_1_lower_label": "Peu probable",
"csat_question_1_upper_label": "Très probable",
"csat_question_2_choice_1": "Plutôt satisfait",
"csat_question_2_choice_2": "Très satisfait",
"csat_question_2_choice_1": "Très satisfait",
"csat_question_2_choice_2": "Un peu satisfait",
"csat_question_2_choice_3": "Ni satisfait ni insatisfait",
"csat_question_2_choice_4": "Un peu insatisfait",
"csat_question_2_choice_5": "Très insatisfait",
+2 -21
View File
@@ -63,8 +63,6 @@
"login_with_email": "Bejelentkezés e-mail-címmel",
"lost_access": "Elvesztette a hozzáférést?",
"new_to_formbricks": "Új a Formbicksen?",
"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": "Visszaszerzési kód használata"
},
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
@@ -150,8 +148,6 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"change_organization": "Szervezet módosítása",
"change_workspace": "Munkaterület módosítása",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
"choose_organization": "Szervezet kiválasztása",
@@ -321,9 +317,6 @@
"notifications": "Értesítések",
"number": "Szám",
"off": "Ki",
"offline_all_responses_synced": "Az Ön válasza sikeresen mentésre került.",
"offline_syncing_responses": "Az Ön válaszainak szinkronizálása folyamatban…",
"offline_you_are_offline": "Ön offline állapotban van. Az Ön válasza a böngészőjében tárolásra került, és mentésre kerül, amint ismét online lesz.",
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
@@ -440,7 +433,6 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"terms_of_service": "Felhasználási feltételek",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
},
"general": {
"ai_data_analysis_disabled_for_organization": "Az MI-alapú adatelemzés és adatgazdagítás ki van kapcsolva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_features_not_enabled_for_organization": "Az MI-funkciók nincsenek engedélyezve ennél a szervezetnél.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens funkciói ki vannak kapcsolva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Csak a szervezet tulajdonosai férhetnek hozzá ehhez a beállításhoz.",
"organization_created_successfully": "A szervezet sikeresen létrehozva",
"organization_deleted_successfully": "A szervezet sikeresen törölve",
"organization_deletion_disabled": "A szervezet törlése le van tiltva.",
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
"organization_name": "Szervezet neve",
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
@@ -1782,11 +1769,6 @@
"upper_label": "Felső címke",
"url_filters": "URL szűrők",
"url_not_supported": "Az URL nem támogatott",
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
"validate_id_no_spaces": "A(z) {type} azonosító nem tartalmazhat szóközöket. Kérjük, távolítsa el a szóközöket.",
"validate_id_reserved": "A(z) {type} azonosító \"{field}\" nem engedélyezett. Ez egy fenntartott kulcsszó.",
"validation": {
"add_validation_rule": "Ellenőrzési szabály hozzáadása",
"answer_all_rows": "Válaszoljon az összes sorra",
@@ -2127,7 +2109,6 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
"csat_question_1_lower_label": "Nem valószínű",
"csat_question_1_upper_label": "Nagyon valószínű",
"csat_question_2_choice_1": "Részben elégedett",
"csat_question_2_choice_2": "Nagyon elégedett",
"csat_question_2_choice_1": "Nagyon elégedett",
"csat_question_2_choice_2": "Valamelyest elégedett",
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
"csat_question_2_choice_4": "Valamelyest elégedetlen",
"csat_question_2_choice_5": "Nagyon elégedetlen",
+4 -23
View File
@@ -63,8 +63,6 @@
"login_with_email": "メールアドレスでログイン",
"lost_access": "アクセスを紛失しましたか?",
"new_to_formbricks": "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": "バックアップコードを使用"
},
"saml_connection_error": "問題が発生しました。詳細については、アプリのコンソールを確認してください。",
@@ -150,8 +148,6 @@
"bottom_right": "右下",
"cancel": "キャンセル",
"centered_modal": "中央モーダル",
"change_organization": "組織を変更",
"change_workspace": "ワークスペースを変更",
"choices": "選択肢",
"choose_environment": "環境を選択",
"choose_organization": "組織を選択",
@@ -321,9 +317,6 @@
"notifications": "通知",
"number": "数値",
"off": "オフ",
"offline_all_responses_synced": "回答が正常に保存されました。",
"offline_syncing_responses": "回答を同期中…",
"offline_you_are_offline": "オフラインです。回答はブラウザに保存されており、オンラインに戻ると保存されます。",
"on": "オン",
"only_one_file_allowed": "ファイルは1つのみ許可されています",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
@@ -440,7 +433,6 @@
"team_name": "チーム名",
"team_role": "チームの役割",
"teams": "チーム",
"terms_of_service": "利用規約",
"text": "テキスト",
"time": "時間",
"time_to_finish": "所要時間",
@@ -513,7 +505,7 @@
"forgot_password_email_change_password": "パスワードを変更",
"forgot_password_email_did_not_request": "このリクエストに心当たりのない場合は、このメールを無視してください。",
"forgot_password_email_heading": "パスワードを変更",
"forgot_password_email_link_valid_for_24_hours": "リンクの有効期限は{minutes}分です。",
"forgot_password_email_link_valid_for_24_hours": "このリンクは{minutes}分間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。"
},
"general": {
"ai_data_analysis_disabled_for_organization": "この組織では AI によるデータ分析と拡張が無効になっています。",
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "この組織のAI機能を管理します。",
"ai_features_not_enabled_for_organization": "この組織では AI 機能が有効になっていません。",
"ai_instance_not_configured": "AI は環境変数を使ってインスタンスレベルで設定されます。AI 機能を有効にする前に、管理者に AI_PROVIDER、このプロバイダーの認証情報、および対応するモデル一覧を設定してもらってください。",
"ai_settings_updated_successfully": "AI設定が正常に更新されました",
"ai_smart_tools_disabled_for_organization": "この組織では AI スマートツールが無効になっています。",
"ai_smart_tools_enabled": "スマート機能(AI",
"ai_smart_tools_enabled_description": "AIを活用して、より短時間でより多くのことを達成できます。Formbricksで収集されたデータには一切触れません。アンケートを他の言語に翻訳するなどの用途にのみ使用されます。",
"bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "組織のオーナーのみがこの設定にアクセスできます。",
"organization_created_successfully": "組織を正常に作成しました!",
"organization_deleted_successfully": "組織を正常に削除しました。",
"organization_deletion_disabled": "組織の削除は無効になっています。",
"organization_invite_link_ready": "組織の招待リンクが準備できました!",
"organization_name": "組織名",
"organization_name_description": "組織に分かりやすい名前を付けます。",
@@ -1675,7 +1662,7 @@
"quota_name_placeholder": "例: 年齢 18 から 25 歳 の 参加者",
"quota_updated_successfull_toast": "クオータを更新しました",
"response_limit": "制限",
"save_changes_confirmation_body": "今後の回答のみに影響します。\n 既存のクォータを複製するか、新しいクォータを作成することをお勧めします。",
"save_changes_confirmation_body": "今後の回答のみに影響します。\\n 既存のクォータを複製するか、新しいクォータを作成することをお勧めします。",
"save_changes_confirmation_text": "既存の応答 は クォータ に とどまります",
"select_ending_card": "終了カードを選択",
"upgrade_prompt_title": "上位プランで クォータ を使用",
@@ -1782,11 +1769,6 @@
"upper_label": "上限ラベル",
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"validate_id_duplicate": "{type} IDは質問、非表示フィールド、または変数に既に存在します。",
"validate_id_empty": "{type} IDを入力してください。",
"validate_id_invalid_chars": "{type} IDは使用できません。英数字、ハイフン、アンダースコアのみを使用してください。",
"validate_id_no_spaces": "{type} IDにスペースを含めることはできません。スペースを削除してください。",
"validate_id_reserved": "{type} ID \"{field}\" は使用できません。予約されたキーワードです。",
"validation": {
"add_validation_rule": "検証ルールを追加",
"answer_all_rows": "すべての行に回答してください",
@@ -2127,7 +2109,6 @@
"this_quarter": "今四半期",
"this_year": "今年",
"time_to_complete": "完了までの時間",
"ttc_survey_tooltip": "アンケートの平均完了時間。",
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "この$[projectName]を友人や同僚に勧める可能性はどのくらいありますか?",
"csat_question_1_lower_label": "可能性が低い",
"csat_question_1_upper_label": "可能性が非常に高い",
"csat_question_2_choice_1": "やや満足している",
"csat_question_2_choice_2": "とても満足している",
"csat_question_2_choice_1": "非常に満足",
"csat_question_2_choice_2": "やや満足",
"csat_question_2_choice_3": "満足も不満もない",
"csat_question_2_choice_4": "やや不満",
"csat_question_2_choice_5": "非常に不満",
+5 -24
View File
@@ -63,8 +63,6 @@
"login_with_email": "Inloggen met e-mail",
"lost_access": "Toegang verloren?",
"new_to_formbricks": "Nieuw bij 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": "Gebruik een back-upcode"
},
"saml_connection_error": "Er is iets misgegaan. Controleer uw app-console voor meer details.",
@@ -150,8 +148,6 @@
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
"centered_modal": "Gecentreerd modaal",
"change_organization": "Organisatie wijzigen",
"change_workspace": "Werkruimte wijzigen",
"choices": "Keuzes",
"choose_environment": "Kies omgeving",
"choose_organization": "Kies organisatie",
@@ -256,7 +252,7 @@
"images": "Afbeeldingen",
"import": "Importeren",
"impressions": "Indrukken",
"imprint": "Wettelijke vermeldingen",
"imprint": "Afdruk",
"in_progress": "In uitvoering",
"inactive_surveys": "Inactieve enquêtes",
"integration": "integratie",
@@ -321,9 +317,6 @@
"notifications": "Meldingen",
"number": "Nummer",
"off": "Uit",
"offline_all_responses_synced": "Je reactie is succesvol opgeslagen.",
"offline_syncing_responses": "Je antwoorden synchroniseren…",
"offline_you_are_offline": "Je bent offline. Je reactie is opgeslagen in je browser en wordt bewaard zodra je weer online bent.",
"on": "Op",
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
@@ -373,7 +366,7 @@
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"replace": "Vervangen",
"report_survey": "Enquête melden",
"report_survey": "Verslag enquête",
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"response": "Antwoord",
@@ -440,7 +433,6 @@
"team_name": "Teamnaam",
"team_role": "Teamrol",
"teams": "Teams",
"terms_of_service": "Gebruiksvoorwaarden",
"text": "Tekst",
"time": "Tijd",
"time_to_finish": "Tijd om af te ronden",
@@ -517,7 +509,7 @@
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
"imprint": "Wettelijke vermeldingen",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hé {inviterName}",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
"invite_accepted_email_text": "We wilden je even laten weten dat {inviteeName} je uitnodiging heeft geaccepteerd. Veel plezier met samenwerken!",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis."
},
"general": {
"ai_data_analysis_disabled_for_organization": "AI-gegevensverrijking en -analyse is uitgeschakeld voor deze organisatie.",
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
"ai_features_not_enabled_for_organization": "AI-functies zijn niet ingeschakeld voor deze organisatie.",
"ai_instance_not_configured": "AI wordt op instantieniveau geconfigureerd via omgevingsvariabelen. Vraag je beheerder om AI_PROVIDER, de inloggegevens voor die provider en de bijbehorende modellenlijst in te stellen voordat AI-functies worden ingeschakeld.",
"ai_settings_updated_successfully": "AI-instellingen succesvol bijgewerkt",
"ai_smart_tools_disabled_for_organization": "AI-slimme functies zijn uitgeschakeld voor deze organisatie.",
"ai_smart_tools_enabled": "Slimme functionaliteit (AI)",
"ai_smart_tools_enabled_description": "AI om je te helpen meer te bereiken in minder tijd. Raakt nooit data aan die met Formbricks is verzameld. Wordt alleen gebruikt om bijvoorbeeld enquêtes naar andere talen te vertalen.",
"bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Alleen organisatie-eigenaren hebben toegang tot deze instelling.",
"organization_created_successfully": "Organisatie succesvol aangemaakt!",
"organization_deleted_successfully": "Organisatie succesvol verwijderd.",
"organization_deletion_disabled": "Het verwijderen van organisaties is uitgeschakeld.",
"organization_invite_link_ready": "De uitnodigingslink voor uw organisatie is gereed!",
"organization_name": "Organisatienaam",
"organization_name_description": "Geef uw organisatie een beschrijvende naam.",
@@ -1782,11 +1769,6 @@
"upper_label": "Bovenste etiket",
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"validate_id_duplicate": "{type}-ID bestaat al in vragen, verborgen velden of variabelen.",
"validate_id_empty": "Voer een {type}-ID in.",
"validate_id_invalid_chars": "{type}-ID is niet toegestaan. Gebruik alleen alfanumerieke tekens, koppeltekens of underscores.",
"validate_id_no_spaces": "{type}-ID mag geen spaties bevatten. Verwijder de spaties.",
"validate_id_reserved": "{type}-ID \"{field}\" is niet toegestaan. Dit is een gereserveerd trefwoord.",
"validation": {
"add_validation_rule": "Validatieregel toevoegen",
"answer_all_rows": "Beantwoord alle rijen",
@@ -2127,7 +2109,6 @@
"this_quarter": "Dit kwartaal",
"this_year": "Dit jaar",
"time_to_complete": "Tijd om te voltooien",
"ttc_survey_tooltip": "Gemiddelde tijd om de enquête te voltooien.",
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
"unknown_question_type": "Onbekend vraagtype",
"use_personal_links": "Gebruik persoonlijke links",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Hoe waarschijnlijk is het dat u deze $[projectName] zou aanbevelen aan een vriend of collega?",
"csat_question_1_lower_label": "Niet waarschijnlijk",
"csat_question_1_upper_label": "Zeer waarschijnlijk",
"csat_question_2_choice_1": "Redelijk tevreden",
"csat_question_2_choice_2": "Zeer tevreden",
"csat_question_2_choice_1": "Zeer tevreden",
"csat_question_2_choice_2": "Enigszins tevreden",
"csat_question_2_choice_3": "Noch tevreden, noch ontevreden",
"csat_question_2_choice_4": "Enigszins ontevreden",
"csat_question_2_choice_5": "Zeer ontevreden",
+2 -21
View File
@@ -63,8 +63,6 @@
"login_with_email": "Entrar com Email",
"lost_access": "Perdeu o acesso?",
"new_to_formbricks": "Novo no 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": "Usar um código de backup"
},
"saml_connection_error": "Algo deu errado. Por favor, verifica o console do app para mais detalhes.",
@@ -150,8 +148,6 @@
"bottom_right": "Canto Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"change_organization": "Alterar organização",
"change_workspace": "Alterar espaço de trabalho",
"choices": "Escolhas",
"choose_environment": "Escolher ambiente",
"choose_organization": "Escolher organização",
@@ -321,9 +317,6 @@
"notifications": "Notificações",
"number": "Número",
"off": "desligado",
"offline_all_responses_synced": "Sua resposta foi salva com sucesso.",
"offline_syncing_responses": "Sincronizando suas respostas…",
"offline_you_are_offline": "Você está offline. Sua resposta está armazenada no seu navegador e será salva quando você voltar a ficar online.",
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
@@ -440,7 +433,6 @@
"team_name": "Nome da equipe",
"team_role": "Função na equipe",
"teams": "Equipes",
"terms_of_service": "Termos de Serviço",
"text": "Texto",
"time": "tempo",
"time_to_finish": "Hora de terminar",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
},
"general": {
"ai_data_analysis_disabled_for_organization": "A análise e o enriquecimento de dados com IA estão desativados para esta organização.",
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
"ai_features_not_enabled_for_organization": "Os recursos de IA não estão habilitados para esta organização.",
"ai_instance_not_configured": "A IA é configurada no nível da instância por meio de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse provedor e a lista de modelos correspondente antes de habilitar os recursos de IA.",
"ai_settings_updated_successfully": "Configurações de IA atualizadas com sucesso",
"ai_smart_tools_disabled_for_organization": "As funcionalidades inteligentes de IA estão desativadas para esta organização.",
"ai_smart_tools_enabled": "Funcionalidades inteligentes (IA)",
"ai_smart_tools_enabled_description": "IA para ajudar você a conquistar mais em menos tempo. Nunca acessa dados coletados com o Formbricks. Usado apenas para, por exemplo, traduzir pesquisas para outros idiomas.",
"bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Somente o Dono pode deletar a organização.",
"organization_created_successfully": "Organização criada com sucesso!",
"organization_deleted_successfully": "Organização deletada com sucesso.",
"organization_deletion_disabled": "A exclusão de organizações está desativada.",
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
"organization_name": "Nome da Organização",
"organization_name_description": "Dê um nome descritivo pra sua organização.",
@@ -1782,11 +1769,6 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"validate_id_duplicate": "O ID de {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, insira um ID de {type}.",
"validate_id_invalid_chars": "O ID de {type} não é permitido. Por favor, use apenas caracteres alfanuméricos, hífens ou underscores.",
"validate_id_no_spaces": "O ID de {type} não pode conter espaços. Por favor, remova os espaços.",
"validate_id_reserved": "O ID de {type} \"{field}\" não é permitido. É uma palavra reservada.",
"validation": {
"add_validation_rule": "Adicionar regra de validação",
"answer_all_rows": "Responda todas as linhas",
@@ -2127,7 +2109,6 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar a pesquisa.",
"ttc_tooltip": "Tempo médio para completar a pergunta.",
"unknown_question_type": "Tipo de pergunta desconhecido",
"use_personal_links": "Use links pessoais",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Qual a probabilidade de você recomendar este $[projectName] para um amigo ou colega?",
"csat_question_1_lower_label": "Pouco provável",
"csat_question_1_upper_label": "Muito provável",
"csat_question_2_choice_1": "Um pouco satisfeito",
"csat_question_2_choice_2": "Muito satisfeito",
"csat_question_2_choice_1": "Muito satisfeito",
"csat_question_2_choice_2": "Meio satisfeito",
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
"csat_question_2_choice_4": "Um pouco insatisfeito",
"csat_question_2_choice_5": "Muito insatisfeito",
+3 -22
View File
@@ -63,8 +63,6 @@
"login_with_email": "Iniciar sessão com Email",
"lost_access": "Perdeu o acesso?",
"new_to_formbricks": "Novo no 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 um código de backup"
},
"saml_connection_error": "Algo correu mal. Por favor, verifique a consola da aplicação para mais detalhes.",
@@ -150,8 +148,6 @@
"bottom_right": "Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"change_organization": "Alterar organização",
"change_workspace": "Alterar espaço de trabalho",
"choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
"choose_organization": "Escolher organização",
@@ -321,9 +317,6 @@
"notifications": "Notificações",
"number": "Número",
"off": "Desligado",
"offline_all_responses_synced": "A tua resposta foi guardada com sucesso.",
"offline_syncing_responses": "A sincronizar as tuas respostas…",
"offline_you_are_offline": "Estás offline. A tua resposta está armazenada no teu navegador e será guardada quando voltares a estar online.",
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
@@ -440,7 +433,6 @@
"team_name": "Nome da equipa",
"team_role": "Função na equipa",
"teams": "Equipas",
"terms_of_service": "Termos de Serviço",
"text": "Texto",
"time": "Tempo",
"time_to_finish": "Tempo para concluir",
@@ -513,7 +505,7 @@
"forgot_password_email_change_password": "Alterar palavra-passe",
"forgot_password_email_did_not_request": "Se não solicitou isto, por favor ignore este email.",
"forgot_password_email_heading": "Alterar palavra-passe",
"forgot_password_email_link_valid_for_24_hours": "A ligação é válida durante {minutes} minutos.",
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
"hidden_field": "Campo oculto",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
},
"general": {
"ai_data_analysis_disabled_for_organization": "A análise e o enriquecimento de dados com IA estão desativados para esta organização.",
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
"ai_enabled": "IA da Formbricks",
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
"ai_features_not_enabled_for_organization": "As funcionalidades de IA não estão ativadas para esta organização.",
"ai_instance_not_configured": "A IA é configurada ao nível da instância através de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse fornecedor e a lista de modelos correspondente antes de ativar as funcionalidades de IA.",
"ai_settings_updated_successfully": "Definições de IA atualizadas com sucesso",
"ai_smart_tools_disabled_for_organization": "As funcionalidades inteligentes de IA estão desativadas para esta organização.",
"ai_smart_tools_enabled": "Funcionalidade inteligente (IA)",
"ai_smart_tools_enabled_description": "IA para te ajudar a alcançar mais em menos tempo. Nunca acede aos dados recolhidos com o Formbricks. Apenas usado para, por exemplo, traduzir inquéritos para outros idiomas.",
"bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Apenas os proprietários da organização podem aceder a esta configuração.",
"organization_created_successfully": "Organização criada com sucesso!",
"organization_deleted_successfully": "Organização eliminada com sucesso.",
"organization_deletion_disabled": "A eliminação de organizações está desativada.",
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
"organization_name": "Nome da Organização",
"organization_name_description": "Dê à sua organização um nome descritivo.",
@@ -1782,11 +1769,6 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"validate_id_duplicate": "O ID {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, introduza um ID {type}.",
"validate_id_invalid_chars": "O ID {type} não é permitido. Por favor, utilize apenas caracteres alfanuméricos, hífenes ou underscores.",
"validate_id_no_spaces": "O ID {type} não pode conter espaços. Por favor, remova os espaços.",
"validate_id_reserved": "O ID {type} \"{field}\" não é permitido. É uma palavra reservada.",
"validation": {
"add_validation_rule": "Adicionar regra de validação",
"answer_all_rows": "Responda a todas as linhas",
@@ -2127,7 +2109,6 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar o inquérito.",
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
"unknown_question_type": "Tipo de Pergunta Desconhecido",
"use_personal_links": "Utilize links pessoais",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Qual a probabilidade de recomendar este $[projectName] a um amigo ou colega?",
"csat_question_1_lower_label": "Pouco provável",
"csat_question_1_upper_label": "Muito provável",
"csat_question_2_choice_1": "Algo satisfeito",
"csat_question_2_choice_2": "Muito satisfeito",
"csat_question_2_choice_1": "Muito satisfeito",
"csat_question_2_choice_2": "Algo satisfeito",
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
"csat_question_2_choice_4": "Algo insatisfeito",
"csat_question_2_choice_5": "Muito insatisfeito",
+3 -22
View File
@@ -63,8 +63,6 @@
"login_with_email": "Autentifică-te cu email",
"lost_access": "Acces pierdut?",
"new_to_formbricks": "Nou în 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": "Folosiți un cod de rezervă"
},
"saml_connection_error": "Ceva nu a mers. Vă rugăm să verificați consola aplicației pentru mai multe detalii.",
@@ -150,8 +148,6 @@
"bottom_right": "Dreapta Jos",
"cancel": "Anulare",
"centered_modal": "Modală centralizată",
"change_organization": "Schimbă organizația",
"change_workspace": "Schimbă spațiul de lucru",
"choices": "Alegeri",
"choose_environment": "Alege mediul",
"choose_organization": "Alege organizația",
@@ -321,9 +317,6 @@
"notifications": "Notificări",
"number": "Număr",
"off": "Oprit",
"offline_all_responses_synced": "Răspunsul tău a fost salvat cu succes.",
"offline_syncing_responses": "Se sincronizează răspunsurile tale…",
"offline_you_are_offline": "Ești offline. Răspunsul tău este stocat în browser și va fi salvat când vei fi din nou online.",
"on": "Pe",
"only_one_file_allowed": "Este permis doar un fișier",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
@@ -440,7 +433,6 @@
"team_name": "Nume echipă",
"team_role": "Rol în echipă",
"teams": "Echipe",
"terms_of_service": "Termeni și condiții",
"text": "Text",
"time": "Timp",
"time_to_finish": "Timp până la finalizare",
@@ -513,7 +505,7 @@
"forgot_password_email_change_password": "Schimbați parola",
"forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.",
"forgot_password_email_heading": "Schimbați parola",
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil {minutes} minute.",
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de {minutes} minute.",
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
"hidden_field": "Câmp ascuns",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile."
},
"general": {
"ai_data_analysis_disabled_for_organization": "Analiza și îmbogățirea datelor cu AI sunt dezactivate pentru această organizație.",
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
"ai_features_not_enabled_for_organization": "Funcționalitățile AI nu sunt activate pentru această organizație.",
"ai_instance_not_configured": "AI este configurată la nivel de instanță prin variabile de mediu. Cere administratorului să configureze AI_PROVIDER, credențialele acelui furnizor și lista de modele corespunzătoare înainte de a activa funcționalitățile AI.",
"ai_settings_updated_successfully": "Setările AI au fost actualizate cu succes",
"ai_smart_tools_disabled_for_organization": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
"ai_smart_tools_enabled": "Funcționalitate inteligentă (AI)",
"ai_smart_tools_enabled_description": "AI care te ajută să faci mai mult în mai puțin timp. Nu accesează niciodată datele colectate cu Formbricks. Folosit doar, de exemplu, pentru a traduce chestionare în alte limbi.",
"bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Doar proprietarii organizației pot accesa această setare.",
"organization_created_successfully": "Organizație creată cu succes!",
"organization_deleted_successfully": "Organizație ștearsă cu succes!",
"organization_deletion_disabled": "Ștergerea organizațiilor este dezactivată.",
"organization_invite_link_ready": "Linkul de invitație al organizației tale este gata!",
"organization_name": "Nume Organizație",
"organization_name_description": "Oferiți organizației dumneavoastră un nume descriptiv.",
@@ -1782,11 +1769,6 @@
"upper_label": "Etichetă superioară",
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"validate_id_duplicate": "ID-ul {type} există deja în întrebări, câmpuri ascunse sau variabile.",
"validate_id_empty": "Te rugăm să introduci un ID {type}.",
"validate_id_invalid_chars": "ID-ul {type} nu este permis. Te rugăm să folosești doar caractere alfanumerice, cratimă sau liniuță de subliniere.",
"validate_id_no_spaces": "ID-ul {type} nu poate conține spații. Te rugăm să elimini spațiile.",
"validate_id_reserved": "ID-ul {type} \"{field}\" nu este permis. Este un cuvânt cheie rezervat.",
"validation": {
"add_validation_rule": "Adaugă regulă de validare",
"answer_all_rows": "Răspunde la toate rândurile",
@@ -2127,7 +2109,6 @@
"this_quarter": "Trimestrul acesta",
"this_year": "Anul acesta",
"time_to_complete": "Timp de finalizare",
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
"unknown_question_type": "Tip de întrebare necunoscut",
"use_personal_links": "Folosește linkuri personale",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Cât de probabil este ca să recomandați acest $[projectName] unui prieten sau coleg?",
"csat_question_1_lower_label": "Puțin probabil",
"csat_question_1_upper_label": "Foarte probabil",
"csat_question_2_choice_1": "Oarecum mulțumit",
"csat_question_2_choice_2": "Foarte mulțumit",
"csat_question_2_choice_1": "Foarte mulțumit",
"csat_question_2_choice_2": "Puțin mulțumit",
"csat_question_2_choice_3": "Nici mulțumit, nici nemulțumit",
"csat_question_2_choice_4": "Ușor nemulțumit",
"csat_question_2_choice_5": "Foarte nemulțumit",
+8 -27
View File
@@ -63,8 +63,6 @@
"login_with_email": "Войти по email",
"lost_access": "Потеряли доступ?",
"new_to_formbricks": "Впервые на 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": "Использовать резервный код"
},
"saml_connection_error": "Что-то пошло не так. Пожалуйста, проверьте консоль приложения для получения подробностей.",
@@ -150,8 +148,6 @@
"bottom_right": "Внизу справа",
"cancel": "Отмена",
"centered_modal": "Центрированное модальное окно",
"change_organization": "Сменить организацию",
"change_workspace": "Сменить рабочее пространство",
"choices": "Варианты",
"choose_environment": "Выберите среду",
"choose_organization": "Выберите организацию",
@@ -321,9 +317,6 @@
"notifications": "Уведомления",
"number": "Номер",
"off": "Выкл.",
"offline_all_responses_synced": "Твой ответ успешно сохранён.",
"offline_syncing_responses": "Синхронизируем твои ответы…",
"offline_you_are_offline": "Ты не в сети. Твой ответ сохранён в браузере и будет отправлен, когда подключение восстановится.",
"on": "Вкл.",
"only_one_file_allowed": "Разрешён только один файл",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
@@ -440,7 +433,6 @@
"team_name": "Название команды",
"team_role": "Роль в команде",
"teams": "Команды",
"terms_of_service": "Условия использования",
"text": "Текст",
"time": "Время",
"time_to_finish": "Время до завершения",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней."
},
"general": {
"ai_data_analysis_disabled_for_organization": "ИИ-анализ и обогащение данных отключены для этой организации.",
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
"ai_features_not_enabled_for_organization": "Функции ИИ не включены для этой организации.",
"ai_instance_not_configured": "ИИ настраивается на уровне инстанса через переменные окружения. Попросите администратора настроить AI_PROVIDER, учетные данные этого провайдера и соответствующий список моделей перед включением функций ИИ.",
"ai_settings_updated_successfully": "Настройки AI успешно обновлены",
"ai_smart_tools_disabled_for_organization": "Интеллектуальные функции ИИ отключены для этой организации.",
"ai_smart_tools_enabled": "Умные функции (ИИ)",
"ai_smart_tools_enabled_description": "ИИ помогает тебе делать больше за меньшее время. Никогда не использует данные, собранные с помощью Formbricks. Применяется, например, для перевода опросов на другие языки.",
"bulk_invite_warning_description": "В бесплатном тарифе всем участникам организации всегда назначается роль \"Владелец\".",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Только владельцы организации имеют доступ к этой настройке.",
"organization_created_successfully": "Организация успешно создана!",
"organization_deleted_successfully": "Организация успешно удалена.",
"organization_deletion_disabled": "Удаление организаций отключено.",
"organization_invite_link_ready": "Ссылка для приглашения в организацию готова!",
"organization_name": "Название организации",
"organization_name_description": "Дайте вашей организации понятное название.",
@@ -1782,11 +1769,6 @@
"upper_label": "Верхняя метка",
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"validate_id_duplicate": "ID {type} уже существует в вопросах, скрытых полях или переменных.",
"validate_id_empty": "Пожалуйста, введите ID {type}.",
"validate_id_invalid_chars": "ID {type} недопустим. Пожалуйста, используйте только буквенно-цифровые символы, дефисы или подчеркивания.",
"validate_id_no_spaces": "ID {type} не может содержать пробелы. Пожалуйста, удалите пробелы.",
"validate_id_reserved": "ID {type} \"{field}\" недопустим. Это зарезервированное ключевое слово.",
"validation": {
"add_validation_rule": "Добавить правило проверки",
"answer_all_rows": "Ответьте на все строки",
@@ -2127,7 +2109,6 @@
"this_quarter": "В этом квартале",
"this_year": "В этом году",
"time_to_complete": "Время на прохождение",
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
"ttc_tooltip": "Среднее время на ответ на вопрос.",
"unknown_question_type": "Неизвестный тип вопроса",
"use_personal_links": "Использовать персональные ссылки",
@@ -2599,11 +2580,11 @@
"churn_survey_question_2_headline": "Что бы сделало использование $[projectName] проще?",
"churn_survey_question_3_button_label": "Получить скидку 30%",
"churn_survey_question_3_headline": "Получите скидку 30% на следующий год!",
"churn_survey_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы бы очень хотели, чтобы вы остались нашим клиентом. Готовы предложить вам скидку 30% на следующий год.</span></p>",
"churn_survey_question_3_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Мы бы очень хотели, чтобы вы остались нашим клиентом. Готовы предложить вам скидку 30% на следующий год.</span></p>",
"churn_survey_question_4_headline": "Каких функций вам не хватает?",
"churn_survey_question_5_button_label": "Написать письмо CEO",
"churn_survey_question_5_headline": "Очень жаль это слышать 😔 Свяжитесь напрямую с нашим CEO!",
"churn_survey_question_5_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы стремимся предоставлять лучший сервис для клиентов. Пожалуйста, напишите нашему CEO, и она лично займётся вашей проблемой.</span></p>",
"churn_survey_question_5_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Мы стремимся предоставлять лучший сервис для клиентов. Пожалуйста, напишите нашему CEO, и она лично займётся вашей проблемой.</span></p>",
"collect_feedback_description": "Соберите подробную обратную связь о вашем продукте или услуге.",
"collect_feedback_name": "Сбор отзывов",
"collect_feedback_question_1_headline": "Как вы оцениваете свой общий опыт?",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Насколько вероятно, что вы порекомендуете $[projectName] другу или коллеге?",
"csat_question_1_lower_label": "Маловероятно",
"csat_question_1_upper_label": "Очень вероятно",
"csat_question_2_choice_1": "Скорее доволен",
"csat_question_2_choice_2": "Очень доволен",
"csat_question_2_choice_1": "Очень доволен",
"csat_question_2_choice_2": "В целом доволен",
"csat_question_2_choice_3": "Ни доволен, ни недоволен",
"csat_question_2_choice_4": "В целом недоволен",
"csat_question_2_choice_5": "Очень недоволен",
@@ -2795,7 +2776,7 @@
"evaluate_a_product_idea_name": "Оценка идеи продукта",
"evaluate_a_product_idea_question_1_button_label": "Давайте попробуем!",
"evaluate_a_product_idea_question_1_headline": "Нам нравится, как вы используете $[projectName]! Мы хотели бы узнать ваше мнение о новой функции. Есть минутка?",
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы ценим ваше время, поэтому всё коротко 🤸</span></p>",
"evaluate_a_product_idea_question_1_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Мы ценим ваше время, поэтому всё коротко 🤸</span></p>",
"evaluate_a_product_idea_question_2_headline": "Спасибо! Насколько сложно или легко вам сейчас решать [PROBLEM AREA]?",
"evaluate_a_product_idea_question_2_lower_label": "Очень сложно",
"evaluate_a_product_idea_question_2_upper_label": "Очень легко",
@@ -2803,7 +2784,7 @@
"evaluate_a_product_idea_question_3_placeholder": "Введите ваш ответ здесь...",
"evaluate_a_product_idea_question_4_button_label": "Далее",
"evaluate_a_product_idea_question_4_headline": "Мы работаем над идеей, которая поможет с [PROBLEM AREA].",
"evaluate_a_product_idea_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Вставьте краткое описание концепции. Добавьте необходимые детали, но сделайте текст лаконичным и понятным.</span></p>",
"evaluate_a_product_idea_question_4_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Вставьте краткое описание концепции. Добавьте необходимые детали, но сделайте текст лаконичным и понятным.</span></p>",
"evaluate_a_product_idea_question_5_headline": "Насколько ценна для вас была бы эта функция?",
"evaluate_a_product_idea_question_5_lower_label": "Не ценна",
"evaluate_a_product_idea_question_5_upper_label": "Очень ценна",
@@ -2898,7 +2879,7 @@
"identify_sign_up_barriers_question_8_placeholder": "Введите ваш ответ здесь...",
"identify_sign_up_barriers_question_9_button_label": "Зарегистрироваться",
"identify_sign_up_barriers_question_9_headline": "Спасибо! Вот ваш код: SIGNUPNOW10",
"identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Большое спасибо, что нашли время поделиться своим мнением 🙏</span></p>",
"identify_sign_up_barriers_question_9_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Большое спасибо, что нашли время поделиться своим мнением 🙏</span></p>",
"identify_upsell_opportunities_description": "Узнайте, сколько времени ваш продукт экономит пользователю. Используйте это для апсейла.",
"identify_upsell_opportunities_name": "Поиск возможностей для апсейла",
"identify_upsell_opportunities_question_1_choice_1": "Меньше 1 часа",
@@ -3140,7 +3121,7 @@
"product_market_fit_superhuman_description": "Оцените PMF, выяснив, насколько пользователи были бы разочарованы, если бы ваш продукт исчез.",
"product_market_fit_superhuman_question_1_button_label": "Рады помочь!",
"product_market_fit_superhuman_question_1_headline": "Вы один из наших активных пользователей! У вас есть 5 минут?",
"product_market_fit_superhuman_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Нам бы хотелось лучше понять ваш пользовательский опыт. Ваше мнение очень важно для нас.</span></p>",
"product_market_fit_superhuman_question_1_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Нам бы хотелось лучше понять ваш пользовательский опыт. Ваше мнение очень важно для нас.</span></p>",
"product_market_fit_superhuman_question_2_choice_1": "Совсем не расстроюсь",
"product_market_fit_superhuman_question_2_choice_2": "Буду немного расстроен",
"product_market_fit_superhuman_question_2_choice_3": "Буду очень расстроен",
+2 -21
View File
@@ -63,8 +63,6 @@
"login_with_email": "Logga in med e-post",
"lost_access": "Förlorad åtkomst?",
"new_to_formbricks": "Ny på 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": "Använd en reservkod"
},
"saml_connection_error": "Något gick fel. Kontrollera din appkonsol för mer information.",
@@ -150,8 +148,6 @@
"bottom_right": "Nedre höger",
"cancel": "Avbryt",
"centered_modal": "Centrerad modal",
"change_organization": "Byt organisation",
"change_workspace": "Byt arbetsyta",
"choices": "Val",
"choose_environment": "Välj miljö",
"choose_organization": "Välj organisation",
@@ -321,9 +317,6 @@
"notifications": "Aviseringar",
"number": "Nummer",
"off": "Av",
"offline_all_responses_synced": "Ditt svar har sparats.",
"offline_syncing_responses": "Synkroniserar dina svar…",
"offline_you_are_offline": "Du är offline. Ditt svar är lagrat i din webbläsare och kommer att sparas när du är online igen.",
"on": "På",
"only_one_file_allowed": "Endast en fil är tillåten",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
@@ -440,7 +433,6 @@
"team_name": "Teamnamn",
"team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"terms_of_service": "Användarvillkor",
"text": "Text",
"time": "Tid",
"time_to_finish": "Tid att slutföra",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar."
},
"general": {
"ai_data_analysis_disabled_for_organization": "AI-baserad dataanalys och databerikning är inaktiverad för den här organisationen.",
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
"ai_features_not_enabled_for_organization": "AI-funktioner är inte aktiverade för den här organisationen.",
"ai_instance_not_configured": "AI konfigureras på instansnivå via miljövariabler. Be din administratör att ange AI_PROVIDER, autentiseringsuppgifterna för den leverantören och den tillhörande modellistan innan AI-funktioner aktiveras.",
"ai_settings_updated_successfully": "AI-inställningarna har uppdaterats",
"ai_smart_tools_disabled_for_organization": "AI-smartverktyg är inaktiverade för den här organisationen.",
"ai_smart_tools_enabled": "Smarta funktioner (AI)",
"ai_smart_tools_enabled_description": "AI som hjälper dig att göra mer på kortare tid. Rör aldrig data som samlats in med Formbricks. Används bara till t.ex. att översätta enkäter till andra språk.",
"bulk_invite_warning_description": "På gratisplanen tilldelas alla organisationsmedlemmar alltid rollen \"Ägare\".",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "Endast organisationsägare kan komma åt denna inställning.",
"organization_created_successfully": "Organisation skapad!",
"organization_deleted_successfully": "Organisation borttagen.",
"organization_deletion_disabled": "Borttagning av organisationer är inaktiverad.",
"organization_invite_link_ready": "Din organisationsinbjudningslänk är redo!",
"organization_name": "Organisationsnamn",
"organization_name_description": "Ge din organisation ett beskrivande namn.",
@@ -1782,11 +1769,6 @@
"upper_label": "Övre etikett",
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"validate_id_duplicate": "{type}-ID finns redan i frågor, dolda fält eller variabler.",
"validate_id_empty": "Vänligen ange ett {type}-ID.",
"validate_id_invalid_chars": "{type}-ID är inte tillåtet. Använd endast alfanumeriska tecken, bindestreck eller understreck.",
"validate_id_no_spaces": "{type}-ID kan inte innehålla mellanslag. Vänligen ta bort mellanslag.",
"validate_id_reserved": "{type}-ID \"{field}\" är inte tillåtet. Det är ett reserverat nyckelord.",
"validation": {
"add_validation_rule": "Lägg till valideringsregel",
"answer_all_rows": "Svara på alla rader",
@@ -2127,7 +2109,6 @@
"this_quarter": "Detta kvartal",
"this_year": "Detta år",
"time_to_complete": "Tid att slutföra",
"ttc_survey_tooltip": "Genomsnittlig tid för att slutföra enkäten.",
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
"unknown_question_type": "Okänd frågetyp",
"use_personal_links": "Använd personliga länkar",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "Hur troligt är det att du skulle rekommendera $[projectName] till en vän eller kollega?",
"csat_question_1_lower_label": "Inte troligt",
"csat_question_1_upper_label": "Mycket troligt",
"csat_question_2_choice_1": "Ganska nöjd",
"csat_question_2_choice_2": "Mycket nöjd",
"csat_question_2_choice_1": "Mycket nöjd",
"csat_question_2_choice_2": "Ganska nöjd",
"csat_question_2_choice_3": "Varken nöjd eller missnöjd",
"csat_question_2_choice_4": "Ganska missnöjd",
"csat_question_2_choice_5": "Mycket missnöjd",
+3 -22
View File
@@ -63,8 +63,6 @@
"login_with_email": "使用 邮箱 登录",
"lost_access": "失去访问?",
"new_to_formbricks": "新用户 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": "使用 备用代码"
},
"saml_connection_error": "出了问题。请查看应用 控制台 以获取更多详情。",
@@ -150,8 +148,6 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "居中 模态",
"change_organization": "切换组织",
"change_workspace": "切换工作区",
"choices": "选项",
"choose_environment": "选择 环境",
"choose_organization": "选择 组织",
@@ -321,9 +317,6 @@
"notifications": "通知",
"number": "数字",
"off": "关闭",
"offline_all_responses_synced": "您的回复已成功保存。",
"offline_syncing_responses": "正在同步您的回复…",
"offline_you_are_offline": "您当前处于离线状态。您的回复已存储在浏览器中,将在您重新联网后保存。",
"on": "开启",
"only_one_file_allowed": "只 允许 一个 文件",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
@@ -440,7 +433,6 @@
"team_name": "团队 名称",
"team_role": "团队角色",
"teams": "团队",
"terms_of_service": "服务条款",
"text": "文本",
"time": "时间",
"time_to_finish": "完成 时间",
@@ -513,7 +505,7 @@
"forgot_password_email_change_password": "更改 密码",
"forgot_password_email_did_not_request": "如果您 未 请求此 项 ,请 忽略 此邮件 。",
"forgot_password_email_heading": "更改 密码",
"forgot_password_email_link_valid_for_24_hours": "链接有效期为 {minutes} 分钟。",
"forgot_password_email_link_valid_for_24_hours": "链接{minutes}分钟内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。"
},
"general": {
"ai_data_analysis_disabled_for_organization": "此组织的 AI 数据分析和增强功能已禁用。",
"ai_data_analysis_enabled": "数据增强与分析(AI",
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
"ai_features_not_enabled_for_organization": "此组织未启用 AI 功能。",
"ai_instance_not_configured": "AI 通过环境变量在实例级别进行配置。启用 AI 功能前,请让管理员设置 AI_PROVIDER、该提供商的凭据以及对应的模型列表。",
"ai_settings_updated_successfully": "AI 设置已成功更新",
"ai_smart_tools_disabled_for_organization": "此组织的 AI 智能功能已禁用。",
"ai_smart_tools_enabled": "智能功能(AI",
"ai_smart_tools_enabled_description": "AI 帮你更高效地完成更多任务。绝不会接触 Formbricks 收集的数据,仅用于如问卷翻译等功能。",
"bulk_invite_warning_description": "在免费计划中,所有组织成员都会被分配为 \"Owner \"角色。",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "只有 组织 所有者 可以 访问 此 设置。",
"organization_created_successfully": "组织 创建 成功",
"organization_deleted_successfully": "组织 删除 成功",
"organization_deletion_disabled": "组织删除已禁用。",
"organization_invite_link_ready": "您的组织邀请链接已准备好!",
"organization_name": "组织 名称",
"organization_name_description": "为您的组织 提供一个描述性的名称",
@@ -1782,11 +1769,6 @@
"upper_label": "上限标签",
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"validate_id_duplicate": "{type} ID 已存在于问题、隐藏字段或变量中。",
"validate_id_empty": "请输入 {type} ID。",
"validate_id_invalid_chars": "{type} ID 不允许使用。请仅使用字母数字字符、连字符或下划线。",
"validate_id_no_spaces": "{type} ID 不能包含空格。请删除空格。",
"validate_id_reserved": "{type} ID \"{field}\" 不允许使用。它是保留关键字。",
"validation": {
"add_validation_rule": "添加验证规则",
"answer_all_rows": "请填写所有行",
@@ -2127,7 +2109,6 @@
"this_quarter": "本季度",
"this_year": "今年",
"time_to_complete": "完成时间",
"ttc_survey_tooltip": "完成调查的平均时间。",
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "您有多大可能向朋友或同事推荐这款 $[projectName] ",
"csat_question_1_lower_label": "不可能",
"csat_question_1_upper_label": "非常 可能",
"csat_question_2_choice_1": "比较满意",
"csat_question_2_choice_2": "非常满意",
"csat_question_2_choice_1": "非常 满意",
"csat_question_2_choice_2": "有点 满意",
"csat_question_2_choice_3": "既不 满意 也 不 不满意",
"csat_question_2_choice_4": "有点 不满意",
"csat_question_2_choice_5": "非常 不 满意",
+3 -22
View File
@@ -63,8 +63,6 @@
"login_with_email": "使用電子郵件登入",
"lost_access": "無法存取?",
"new_to_formbricks": "初次使用 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": "使用備份碼"
},
"saml_connection_error": "發生錯誤。請檢查您的 app 主控台以取得更多詳細資料。",
@@ -150,8 +148,6 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "置中彈窗",
"change_organization": "變更組織",
"change_workspace": "變更工作區",
"choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
@@ -321,9 +317,6 @@
"notifications": "通知",
"number": "數字",
"off": "關閉",
"offline_all_responses_synced": "你的回應已成功儲存。",
"offline_syncing_responses": "正在同步你的回應…",
"offline_you_are_offline": "你目前離線。你的回應已暫存在瀏覽器中,並將在恢復網路連線後自動儲存。",
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
@@ -440,7 +433,6 @@
"team_name": "團隊名稱",
"team_role": "團隊角色",
"teams": "團隊",
"terms_of_service": "服務條款",
"text": "文字",
"time": "時間",
"time_to_finish": "完成時間",
@@ -513,7 +505,7 @@
"forgot_password_email_change_password": "變更密碼",
"forgot_password_email_did_not_request": "如果您沒有要求此操作,請忽略此電子郵件。",
"forgot_password_email_heading": "變更密碼",
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 {minutes} 分鐘。",
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 {minutes} 分鐘。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
@@ -1137,15 +1129,11 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。"
},
"general": {
"ai_data_analysis_disabled_for_organization": "此組織的 AI 資料分析與增強功能已停用。",
"ai_data_analysis_enabled": "資料增強與分析(AI",
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理此組織的 AI 功能。",
"ai_features_not_enabled_for_organization": "此組織尚未啟用 AI 功能。",
"ai_instance_not_configured": "AI 會透過環境變數在實例層級進行設定。啟用 AI 功能前,請管理員設定 AI_PROVIDER、該供應商的憑證,以及對應的模型清單。",
"ai_settings_updated_successfully": "AI 設定已成功更新",
"ai_smart_tools_disabled_for_organization": "此組織的 AI 智慧功能已停用。",
"ai_smart_tools_enabled": "智慧功能(AI",
"ai_smart_tools_enabled_description": "AI 幫你更快完成更多事。絕不會接觸 Formbricks 收集的資料,只用於像是將問卷翻譯成其他語言等用途。",
"bulk_invite_warning_description": "在免費方案中,所有組織成員始終會被指派「擁有者」角色。",
@@ -1186,7 +1174,6 @@
"only_org_owner_can_perform_action": "只有組織擁有者才能存取此設定。",
"organization_created_successfully": "組織已成功建立!",
"organization_deleted_successfully": "組織已成功刪除。",
"organization_deletion_disabled": "組織刪除已停用。",
"organization_invite_link_ready": "您的組織邀請連結已準備就緒!",
"organization_name": "組織名稱",
"organization_name_description": "為您的組織提供描述性名稱。",
@@ -1782,11 +1769,6 @@
"upper_label": "上標籤",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"validate_id_duplicate": "{type} ID 已存在於問題、隱藏欄位或變數中。",
"validate_id_empty": "請輸入 {type} ID。",
"validate_id_invalid_chars": "不允許使用此 {type} ID。請僅使用英數字元、連字號或底線。",
"validate_id_no_spaces": "{type} ID 不能包含空格。請移除空格。",
"validate_id_reserved": "不允許使用 {type} ID「{field}」。這是保留關鍵字。",
"validation": {
"add_validation_rule": "新增驗證規則",
"answer_all_rows": "請填答所有列",
@@ -2127,7 +2109,6 @@
"this_quarter": "本季",
"this_year": "今年",
"time_to_complete": "完成時間",
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
"ttc_tooltip": "完成 問題 的 平均 時間。",
"unknown_question_type": "未知的問題類型",
"use_personal_links": "使用 個人 連結",
@@ -2638,8 +2619,8 @@
"csat_question_1_headline": "您向朋友或同事推薦此 {projectName} 的可能性有多高?",
"csat_question_1_lower_label": "不太可能",
"csat_question_1_upper_label": "非常可能",
"csat_question_2_choice_1": "有點滿意",
"csat_question_2_choice_2": "非常滿意",
"csat_question_2_choice_1": "非常滿意",
"csat_question_2_choice_2": "有點滿意",
"csat_question_2_choice_3": "既不滿意也不不滿意",
"csat_question_2_choice_4": "有點不滿意",
"csat_question_2_choice_5": "非常不滿意",
@@ -163,12 +163,10 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
/>
);
} else if (Array.isArray(responseData)) {
const itemsArray = responseData
.filter((choice) => choice !== "")
.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
return (
<>
{element.type === TSurveyElementTypeEnum.Ranking ? (
@@ -8,7 +8,6 @@ import { TResponse, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -105,11 +104,7 @@ export const SingleResponseCard = ({
if (isReadOnly) {
throw new Error(t("common.not_authorized"));
}
const result = await deleteResponseAction({ responseId: response.id, decrementQuotas });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await deleteResponseAction({ responseId: response.id, decrementQuotas });
updateResponseList?.([response.id]);
if (setSelectedResponseId) setSelectedResponseId(null);
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
@@ -44,22 +44,12 @@ export const authenticatedApiClient = async <S extends ExtendedSchemas>({
return response;
} catch (err) {
if (err !== null && typeof err === "object" && "type" in err) {
return handleApiError(request, err as ApiErrorResponseV2, undefined, err);
return handleApiError(request, err as ApiErrorResponseV2);
}
return handleApiError(
request,
{
type: "internal_server_error",
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
},
undefined,
err
);
return handleApiError(request, {
type: "internal_server_error",
details: [{ field: "error", issue: "An error occurred while processing your request." }],
});
}
};
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { logApiRequest } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "../api-wrapper";
import { authenticatedApiClient } from "../authenticated-api-client";
@@ -8,19 +8,10 @@ vi.mock("../api-wrapper", () => ({
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
logApiRequest: vi.fn(),
}));
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
describe("authenticatedApiClient", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should log request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
@@ -38,60 +29,4 @@ describe("authenticatedApiClient", () => {
expect(response.status).toBe(200);
expect(logApiRequest).toHaveBeenCalled();
});
test("passes the original ApiErrorResponseV2 through to handleApiError", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const apiError = {
type: "internal_server_error",
details: [{ field: "error", issue: "boom" }],
} as const;
const handledResponse = new Response("error", { status: 500 });
vi.mocked(apiWrapper).mockRejectedValue(apiError);
vi.mocked(handleApiError).mockReturnValue(handledResponse);
const handler = vi.fn();
const response = await authenticatedApiClient({
request,
handler,
});
expect(response).toBe(handledResponse);
expect(handleApiError).toHaveBeenCalledWith(request, apiError, undefined, apiError);
});
test("passes unknown thrown errors to handleApiError as originalError", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const thrownError = new Error("boom");
const handledResponse = new Response("error", { status: 500 });
vi.mocked(apiWrapper).mockRejectedValue(thrownError);
vi.mocked(handleApiError).mockReturnValue(handledResponse);
const handler = vi.fn();
const response = await authenticatedApiClient({
request,
handler,
});
expect(response).toBe(handledResponse);
expect(handleApiError).toHaveBeenCalledWith(
request,
{
type: "internal_server_error",
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
},
undefined,
thrownError
);
});
});
+67 -95
View File
@@ -1,18 +1,26 @@
import * as Sentry from "@sentry/nextjs";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost/api/v2/test");
const mockRequest = new Request("http://localhost");
// Add the request id header
mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
withScope: vi.fn((callback: (scope: any) => void) => {
const mockScope = {
setTag: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
}),
}));
// Mock SENTRY_DSN constant while preserving untouched exports.
@@ -29,10 +37,6 @@ vi.mock("@/lib/constants", async (importOriginal) => {
});
describe("utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("handleApiError", () => {
test('return bad request response for "bad_request" error', async () => {
const details = [{ field: "param", issue: "invalid" }];
@@ -118,57 +122,6 @@ describe("utils", () => {
expect(body.error.details).toEqual([
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
]);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "GET",
path: "/api/v2/test",
}),
extra: expect.objectContaining({
error,
originalError: error,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "GET",
path: "/api/v2/test",
status: 500,
}),
}),
})
);
});
test("preserves originalError separately when provided", () => {
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
const originalError = new Error("boom");
handleApiError(mockRequest, error, undefined, originalError);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
extra: expect.objectContaining({
error,
originalError: expect.objectContaining({
message: "boom",
}),
}),
})
);
});
});
@@ -203,11 +156,13 @@ describe("utils", () => {
describe("logApiRequest", () => {
test("logs API request details", () => {
// Mock the withContext method and its returned info method
const infoMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
info: infoMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -217,18 +172,23 @@ describe("utils", () => {
logApiRequest(mockRequest, 200);
// Verify withContext was called
expect(withContextMock).toHaveBeenCalled();
// Verify info was called on the child logger
expect(infoMock).toHaveBeenCalledWith("API Request Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("logs API request details without correlationId and without safe query params", () => {
// Mock the withContext method and its returned info method
const infoMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
info: infoMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -238,6 +198,7 @@ describe("utils", () => {
logApiRequest(mockRequest, 200);
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
@@ -247,19 +208,23 @@ describe("utils", () => {
})
);
// Verify info was called on the child logger
expect(infoMock).toHaveBeenCalledWith("API Request Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
describe("logApiError", () => {
test("logs API error details with method and path", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -273,30 +238,33 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("logs API error details without correlationId", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/test");
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.delete("x-request-id");
const error: ApiErrorResponseV2 = {
@@ -306,26 +274,44 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "",
method: "GET",
path: "/api/v2/test",
path: "/api/test",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("sends internal server errors to Sentry with direct capture context", () => {
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -339,49 +325,31 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
}),
extra: expect.objectContaining({
error,
originalError: error,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
status: 500,
}),
}),
})
);
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
@@ -389,6 +357,7 @@ describe("utils", () => {
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -402,10 +371,13 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(Sentry.withScope).not.toHaveBeenCalled();
// But structured logging should still happen
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
+32 -33
View File
@@ -1,38 +1,37 @@
// Function is this file can be used in edge runtime functions, like api routes.
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
const getStatusFromApiError = (error: ApiErrorResponseV2): number => {
switch (error.type) {
case "bad_request":
return 400;
case "unauthorized":
return 401;
case "forbidden":
return 403;
case "not_found":
return 404;
case "conflict":
return 409;
case "unprocessable_entity":
return 422;
case "too_many_requests":
return 429;
case "internal_server_error":
default:
return 500;
}
};
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
export const logApiErrorEdge = (
request: Request,
error: ApiErrorResponseV2,
originalError: unknown = error
): void => {
reportApiError({
request,
status: getStatusFromApiError(error),
error,
originalError,
});
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err);
});
}
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");
};
+7 -13
View File
@@ -12,10 +12,9 @@ import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: TApiAuditLog,
originalError: unknown = err
auditLog?: TApiAuditLog
): Response => {
logApiError(request, err, auditLog, originalError);
logApiError(request, err, auditLog);
switch (err.type) {
case "bad_request":
@@ -65,9 +64,9 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
const startTime = request.headers.get("x-start-time") || "";
const queryParams = Object.fromEntries(url.searchParams.entries());
const sensitiveParams = new Set(["apikey", "token", "secret"]);
const sensitiveParams = ["apikey", "token", "secret"];
const safeQueryParams = Object.fromEntries(
Object.entries(queryParams).filter(([key]) => !sensitiveParams.has(key.toLowerCase()))
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
);
logger
@@ -75,7 +74,7 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
method,
path,
responseStatus,
duration: `${Date.now() - Number.parseInt(startTime, 10)} ms`,
duration: `${Date.now() - parseInt(startTime)} ms`,
correlationId,
queryParams: safeQueryParams,
})
@@ -84,13 +83,8 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
logAuditLog(request, auditLog);
};
export const logApiError = (
request: Request,
error: ApiErrorResponseV2,
auditLog?: TApiAuditLog,
originalError: unknown = error
): void => {
logApiErrorEdge(request, error, originalError);
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
logApiErrorEdge(request, error);
logAuditLog(request, auditLog);
};

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