Compare commits

..

25 Commits

Author SHA1 Message Date
Tiago Farto b525f7bdbb fix: support script-region survey locales 2026-05-21 15:00:40 +00:00
Tiago Farto 9f9009497e fix: tighten v3 survey locale selectors 2026-05-21 14:40:17 +00:00
Tiago Farto 7ad0f8b21f chore: api v3 get survey 2026-05-21 11:54:46 +00:00
Dhruwang Jariwala f6aa27ba8c fix: chart date range type switch + presets include today (ENG-1034, ENG-1035) (#8096)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-21 11:05:10 +00:00
Johannes 82765f7dd7 fix: allow enterprise oauth display names (#8099)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:59:35 +00:00
Dhruwang Jariwala d5bbafcf90 fix: remount AI translation editor on value change, not disabled transition (#8084) 2026-05-21 10:09:57 +00:00
Anshuman Pandey db87a588b5 fix: adds close button on response error screen (#8093) 2026-05-21 09:26:47 +00:00
Javi Aguilar c834587c8d chore: add typecheck command and fix format and type issues (#7999) 2026-05-21 08:13:46 +00:00
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
190 changed files with 3165 additions and 2678 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: v3.15.4
version: latest
- name: Log in to GitHub Container Registry
env:
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { App } from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -571,285 +571,275 @@ export const MainNavigation = ({
</div>
)}
<div>
{!isSettingsMode && (
<>
{/* New Version Available */}
{!isCollapsed &&
isOwnerOrManager &&
latestVersion &&
!isFormbricksCloud &&
!isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed &&
isFormbricksCloud &&
trialDaysRemaining !== null &&
(newTrialBannerVariant === "new-trial-banner" ? (
<TrialBannerNew
trialDaysRemaining={trialDaysRemaining}
planName={organization.billing.stripe?.plan ?? "pro"}
responseCount={responseCount}
responseLimit={organization.billing.limits.monthly.responses}
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
/>
) : (
<Link
href={`/workspaces/${workspace.id}/settings/organization/billing`}
className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
))}
</>
)}
<div className="flex flex-col">
{!isSettingsMode && (
<>
<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">{workspace.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>
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces &&
!isInitialWorkspacesLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setWorkspaces([]);
},
t("common.try_again")
)}
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === workspace.id}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleWorkspaceCreate}
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 />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
}
className="cursor-pointer">
<Cog className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</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 />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</>
{!isSettingsMode && (
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
<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>
{!isCollapsed && !isTextVisible && (
{/* Trial Days Remaining */}
{!isCollapsed &&
isFormbricksCloud &&
trialDaysRemaining !== null &&
(newTrialBannerVariant === "new-trial-banner" ? (
<TrialBannerNew
trialDaysRemaining={trialDaysRemaining}
planName={organization.billing.stripe?.plan ?? "pro"}
responseCount={responseCount}
responseLimit={organization.billing.limits.monthly.responses}
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
/>
) : (
<Link
href={`/workspaces/${workspace.id}/settings/organization/billing`}
className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</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">{workspace.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>
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces &&
!isInitialWorkspacesLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setWorkspaces([]);
},
t("common.try_again")
)}
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
<>
<div className="grow overflow-hidden">
<p
title={user?.email}
className="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>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === workspace.id}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleWorkspaceCreate}
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>
)}
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
}
className="cursor-pointer">
<Cog className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearWorkspaceId: true,
});
router.push(route?.url || loginUrl);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</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 />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<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>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p
title={user?.email}
className="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>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearWorkspaceId: true,
});
router.push(route?.url || loginUrl);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)}
</aside>
)}
{openWorkspaceLimitModal && (
@@ -374,6 +374,7 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
@@ -1,10 +1,11 @@
const AccountSettingsLayout = async (
props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>
) => {
await props.params;
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
};
@@ -6,7 +6,6 @@ import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/accoun
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/IntegrationsTip";
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/types";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -128,13 +127,7 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships;
};
const Page = async (props: {
params: Promise<{ workspaceId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => {
const searchParams = await props.searchParams;
const t = await getTranslate();
const session = await getServerSession(authOptions);
@@ -66,6 +66,11 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -57,6 +57,7 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
@@ -65,6 +66,7 @@ describe("organization AI settings actions", () => {
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
@@ -112,15 +114,18 @@ describe("organization AI settings actions", () => {
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
@@ -189,6 +194,7 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
@@ -71,11 +71,12 @@ export const updateOrganizationNameAction = authenticatedActionClient
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled"
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
@@ -89,10 +90,16 @@ const resolveOrganizationAISettings = ({
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,
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
};
};
@@ -50,18 +50,29 @@ export const AISettingsToggle = ({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (checked: boolean) => {
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField("isAISmartToolsEnabled");
setLoadingField(field);
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data: { isAISmartToolsEnabled: checked },
data,
});
if (response?.data) {
@@ -111,7 +122,7 @@ export const AISettingsToggle = ({
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={handleToggle}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("workspace.settings.general.ai_smart_tools_enabled")}
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
@@ -119,6 +130,16 @@ export const AISettingsToggle = ({
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("workspace.settings.general.ai_data_analysis_enabled")}
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
@@ -9,6 +9,7 @@ import {
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
@@ -37,11 +38,14 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
const user = session?.user?.id ? await getUser(session.user.id) : null;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
]);
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -4,6 +4,7 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new InvalidInputError("No contacts found for the selected segment");
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -1,10 +1,11 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -33,25 +32,7 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
workspaceId,
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
});
test("returns a bad request response when the response id is missing", async () => {
@@ -245,6 +245,34 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
@@ -6,7 +6,6 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -57,17 +56,11 @@ export const POST = withV1ApiWrapper({
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
responseInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
@@ -218,7 +211,7 @@ export const POST = withV1ApiWrapper({
response: responseData,
});
if (responseInputData.finished) {
if (responseInput.finished) {
await sendToPipeline({
event: "responseFinished",
workspaceId,
@@ -3,7 +3,6 @@ import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classe
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -85,14 +84,8 @@ export const PUT = withV1ApiWrapper({
let actionClassUpdate;
try {
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
actionClassUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -46,14 +45,8 @@ export const POST = withV1ApiWrapper({
try {
let actionClassInput;
try {
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
actionClassInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { TResponseData, ZResponseUpdateInput } from "@formbricks/types/responses";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -13,11 +12,6 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
type TUncheckedResponseUpdate = Record<string, unknown> & {
data: TResponseData;
language?: string;
};
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiV1Authentication | undefined,
@@ -126,16 +120,10 @@ export const PUT = withV1ApiWrapper({
auditLog.oldObject = result.response;
}
let responseUpdate: TUncheckedResponseUpdate;
let responseUpdate;
try {
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
responseUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -92,14 +91,8 @@ export const POST = withV1ApiWrapper({
try {
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
jsonInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -20,14 +19,8 @@ export const POST = withV1ApiWrapper({
let storageInput;
try {
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
storageInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -9,7 +9,6 @@ import {
addLegacyProjectOverwrites,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -23,12 +22,6 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
type TSurveyUpdateBody = Record<string, unknown> & {
blocks?: Parameters<typeof validateSurveyInput>[0]["blocks"];
endings?: Parameters<typeof transformQuestionsToBlocks>[1];
questions?: Parameters<typeof transformQuestionsToBlocks>[0];
};
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
@@ -171,16 +164,10 @@ export const PUT = withV1ApiWrapper({
};
}
let surveyUpdate: TSurveyUpdateBody;
let surveyUpdate;
try {
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
surveyUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -201,7 +188,7 @@ export const PUT = withV1ApiWrapper({
if (hasQuestions) {
surveyUpdate.blocks = transformQuestionsToBlocks(
surveyUpdate.questions ?? [],
surveyUpdate.questions,
surveyUpdate.endings || result.survey.endings
);
surveyUpdate.questions = [];
@@ -221,11 +208,7 @@ export const PUT = withV1ApiWrapper({
};
}
const featureCheckResult = await checkFeaturePermissions(
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
organization,
result.survey
);
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -51,6 +51,7 @@ const mockOrganization: TOrganization = {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithWorkspaceId["followUps"][number] = {
@@ -8,7 +8,6 @@ import {
addLegacyProjectOverwritesToList,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -85,14 +84,8 @@ export const POST = withV1ApiWrapper({
try {
let surveyInput;
try {
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
surveyInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
+2 -9
View File
@@ -2,7 +2,6 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -41,14 +40,8 @@ export const POST = withV1ApiWrapper({
let webhookInput;
try {
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
webhookInput = await req.json();
} catch {
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -2,7 +2,6 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
+2 -11
View File
@@ -4,7 +4,6 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -17,7 +16,6 @@ import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemPayloadTooLarge,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
@@ -172,15 +170,8 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
let bodyData: unknown;
try {
bodyData = await parseJsonBodyWithLimit(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
ok: false,
response: problemPayloadTooLarge(requestId, error.message, instance),
};
}
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
+13 -13
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
@@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
@@ -39,7 +39,7 @@ describe("requireSessionWorkspaceAccess", () => {
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(getWorkspace).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
@@ -55,11 +55,11 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(getWorkspace).not.toHaveBeenCalled();
});
test("returns 403 when workspace is not found (avoid leaking existence)", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"ws_nonexistent",
@@ -72,12 +72,12 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
@@ -102,7 +102,7 @@ describe("requireSessionWorkspaceAccess", () => {
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
@@ -144,7 +144,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" });
vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k");
});
@@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("delegates to session flow when user is present", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
@@ -179,7 +179,7 @@ describe("requireV3WorkspaceAccess", () => {
workspaceId: "proj_k",
organizationId: "org_k",
});
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k");
expect(getWorkspace).toHaveBeenCalledWith("proj_k");
});
test("returns context for API key with write on workspace", async () => {
@@ -239,7 +239,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
const auth = {
...keyBase,
workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)],
+13 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -13,7 +14,7 @@ import {
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
invalid_params: [{ name: "x", reason: "y", identifier: "canonical-x" }],
instance: "/p",
});
expect(res.status).toBe(400);
@@ -21,7 +22,7 @@ describe("v3 problem responses", () => {
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.invalid_params).toEqual([{ name: "x", reason: "y", identifier: "canonical-x" }]);
expect(body.instance).toBe("/p");
});
@@ -118,3 +119,13 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
+16 -12
View File
@@ -6,7 +6,7 @@
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type InvalidParam = { name: string; reason: string; identifier?: string };
export type ProblemExtension = {
code?: string;
@@ -71,17 +71,6 @@ export function problemBadRequest(
});
}
export function problemPayloadTooLarge(
requestId: string,
detail: string = "Payload Too Large",
instance?: string
): Response {
return problemResponse(413, "Payload Too Large", detail, requestId, {
code: "payload_too_large",
instance,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
@@ -182,3 +171,18 @@ export function successResponse<T>(
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -1,45 +1,34 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns workspaceId and organizationId when workspace exists", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("ws_abc");
expect(result).toEqual({
workspaceId: "ws_abc",
organizationId: "org_123",
});
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc");
expect(getWorkspace).toHaveBeenCalledWith("ws_abc");
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_abc");
});
test("resolves legacy environmentId to canonical workspaceId", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_canonical" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_456");
const result = await resolveV3WorkspaceContext("env_legacy");
expect(result).toEqual({
workspaceId: "ws_canonical",
organizationId: "org_456",
});
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_canonical");
});
test("throws when workspace does not exist", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
});
});
+5 -6
View File
@@ -6,7 +6,7 @@
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { getWorkspace } from "@/lib/workspace/service";
/**
* Internal IDs derived from a V3 workspace identifier.
@@ -19,21 +19,20 @@ export type V3WorkspaceContext = {
};
/**
* Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId.
* Resolves a V3 API workspaceId to internal workspaceId and organizationId.
*
* @throws ResourceNotFoundError if the workspace does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId);
const workspace = await getWorkspace(workspaceId);
if (!workspace) {
throw new ResourceNotFoundError("workspace", workspaceId);
}
const canonicalId = workspace.id;
const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId);
const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id);
return {
workspaceId: canonicalId,
workspaceId: workspace.id,
organizationId,
};
}
@@ -1,318 +0,0 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+121 -27
View File
@@ -2,42 +2,141 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { getAuthorizedV3Survey } from "../authorization";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
...(error.normalizedCode && { identifier: error.normalizedCode }),
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
params: surveyParamsSchema,
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
const { survey, authResult, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
survey.workspaceId,
"readWrite",
access: "readWrite",
requestId,
instance
);
instance,
});
if (authResult instanceof Response) {
return authResult;
if (response) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
}
if (auditLog) {
@@ -46,14 +145,9 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
return noContentResponse({ requestId });
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -0,0 +1,71 @@
import { describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { getAuthorizedV3Survey } from "./authorization";
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
const survey = {
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
};
const surveyRecord = survey as unknown as NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
describe("getAuthorizedV3Survey", () => {
test("returns a generic forbidden response when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_1",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response?.status).toBe(403);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns the authorization response when workspace access is denied", async () => {
const forbiddenResponse = new Response(null, { status: 403 });
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(forbiddenResponse);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "readWrite",
requestId: "req_2",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response).toBe(forbiddenResponse);
});
test("returns the survey and authorization context when access is allowed", async () => {
const authResult = { workspaceId: survey.workspaceId, organizationId: "org_1" };
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_3",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result).toEqual({
survey,
authResult,
response: null,
});
});
});
@@ -0,0 +1,37 @@
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden } from "@/app/api/v3/lib/response";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getSurvey } from "@/lib/survey/service";
export async function getAuthorizedV3Survey(params: {
surveyId: string;
authentication: TV3Authentication;
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
@@ -0,0 +1,120 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["zh_hans_cn", "zh-Hans-CN"],
["ZH-hant-tw", "zh-Hant-TW"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
test("returns null for language-only tags", () => {
expect(normalizeV3SurveyLanguageTag("de")).toBeNull();
});
test("returns null for script-only tags without a region", () => {
expect(normalizeV3SurveyLanguageTag("zh_Hans")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us, zh_hans_cn")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US", "zh-Hans-CN"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
test("rejects language-only selectors", () => {
expect(parseV3SurveyLanguageQuery("de")).toEqual({
ok: false,
message: "Language 'de' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("matches configured script-region languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("ZH_hans_cn", [{ code: "zh-Hans-CN", enabled: true }])).toEqual({
ok: true,
code: "zh-Hans-CN",
});
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr-FR", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("ZH_hant_tw", languages)).toEqual({
ok: false,
reason: "unknown",
normalizedCode: "zh-Hant-TW",
message: "Language 'zh-Hant-TW' is not configured for this survey",
});
});
test("rejects language-only tags for surveys with a matching configured language", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({
ok: false,
reason: "invalid",
message: "Language 'de' is not a valid locale code",
});
});
test("resolves the implicit default locale for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en-US", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
+97
View File
@@ -0,0 +1,97 @@
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown"; message: string; normalizedCode?: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
const V3_SURVEY_LOCALE_CODE_REGEX = /^[a-z]{2}(?:-[A-Z][a-z]{3})?-[A-Z]{2}$/;
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
const normalizedLanguage = Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
if (!normalizedLanguage || !V3_SURVEY_LOCALE_CODE_REGEX.test(normalizedLanguage)) {
return null;
}
return normalizedLanguage;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
return {
ok: false,
reason: "unknown",
normalizedCode: normalizedRequestedLanguage,
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
@@ -0,0 +1,290 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: { cx: "enterprise" },
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en-US"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("filters script-region locale selectors while preserving maps", () => {
const survey = {
...baseSurvey,
languages: [
...baseSurvey.languages,
{
default: false,
enabled: true,
language: {
id: "lang_4",
code: "zh-Hans-CN",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", zh_hans_cn: "欢迎" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["ZH_hans_cn"] });
expect(resource).toMatchObject({
welcomeCard: { headline: { "zh-Hans-CN": "欢迎" } },
});
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr-FR"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de-DE"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects language-only selectors", () => {
expect(() => serializeV3SurveyResource(baseSurvey, { lang: ["de"] })).toThrow(
"Language 'de' is not a valid locale code"
);
});
test("exposes the normalized locale code for unknown language errors", () => {
try {
serializeV3SurveyResource(baseSurvey, { lang: ["ES_es"] });
} catch (error) {
if (!(error instanceof V3SurveyLanguageError)) {
throw error;
}
expect(error.message).toBe("Language 'es-ES' is not configured for this survey");
expect(error.normalizedCode).toBe("es-ES");
return;
}
throw new Error("Expected V3SurveyLanguageError");
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+185 -3
View File
@@ -1,13 +1,195 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(
message: string,
readonly normalizedCode?: string
) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
}
return languages;
}
function getDefaultLanguage(survey: TInternalSurvey): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: DEFAULT_V3_SURVEY_LANGUAGE;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message, result.normalizedCode);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: survey.metadata,
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "./request-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
@@ -1,9 +1,8 @@
import { z } from "zod";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body" | "payload_too_large";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
@@ -45,18 +44,10 @@ export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
let jsonInput: unknown;
try {
jsonInput = await parseJsonBodyWithLimit(request);
jsonInput = await request.json();
} catch (error) {
const details = { error: getErrorMessage(error) };
if (error instanceof RequestBodyTooLargeError) {
return {
details,
issue: "payload_too_large",
response: responses.payloadTooLargeResponse("Payload Too Large", details, true),
};
}
return {
details,
issue: "invalid_json",
-90
View File
@@ -1,90 +0,0 @@
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
export class RequestBodyTooLargeError extends Error {
readonly actualBytes: number | null;
readonly limitBytes: number;
constructor(limitBytes: number, actualBytes: number | null = null) {
super(`Request body must not exceed ${limitBytes} bytes`);
this.name = "RequestBodyTooLargeError";
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}
const textDecoder = new TextDecoder();
const getContentLength = (headers: Headers): number | null => {
const contentLength = headers.get("content-length");
if (!contentLength) {
return null;
}
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength < 0) {
return null;
}
return parsedContentLength;
};
const assertBodySize = (actualBytes: number, limitBytes: number): void => {
if (actualBytes > limitBytes) {
throw new RequestBodyTooLargeError(limitBytes, actualBytes);
}
};
export const readRequestBodyWithLimit = async (
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<string> => {
const contentLength = getContentLength(request.headers);
if (contentLength !== null) {
assertBodySize(contentLength, limitBytes);
}
if (!request.body) {
return "";
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > limitBytes) {
await reader.cancel().catch(() => undefined);
throw new RequestBodyTooLargeError(limitBytes, receivedBytes);
}
chunks.push(value);
}
if (chunks.length === 0) {
return "";
}
if (chunks.length === 1) {
return textDecoder.decode(chunks[0]);
}
const body = new Uint8Array(receivedBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return textDecoder.decode(body);
};
export const parseJsonBodyWithLimit = async <TJson = unknown>(
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<TJson> => JSON.parse(await readRequestBodyWithLimit(request, limitBytes)) as TJson;
+1 -27
View File
@@ -17,8 +17,7 @@ interface ApiErrorResponse {
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict"
| "payload_too_large";
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -81,30 +80,6 @@ const badRequestResponse = (
);
};
const payloadTooLargeResponse = (
message: string = "Payload Too Large",
details: ApiErrorResponse["details"] = {},
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "payload_too_large",
message,
details,
},
{
status: 413,
headers,
}
);
};
const methodNotAllowedResponse = (
res: CustomNextApiResponse,
allowedMethods: string[],
@@ -319,7 +294,6 @@ export const responses = {
unauthorizedResponse,
notFoundResponse,
successResponse,
payloadTooLargeResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
-2
View File
@@ -1859,7 +1859,6 @@ checksums:
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
@@ -1894,7 +1893,6 @@ checksums:
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
+65 -3
View File
@@ -3,6 +3,7 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
@@ -12,6 +13,7 @@ const mocks = vi.hoisted(() => ({
generateText: vi.fn(),
isAiConfigured: vi.fn(),
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
loggerError: vi.fn(),
}));
@@ -61,6 +63,7 @@ vi.mock("@/lib/organization/service", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
@@ -72,8 +75,10 @@ describe("AI organization service", () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
});
test("returns the instance AI status and organization settings", async () => {
@@ -84,7 +89,9 @@ describe("AI organization service", () => {
expect(result).toMatchObject({
organizationId: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
isAISmartToolsEntitled: true,
isAIDataAnalysisEntitled: true,
isInstanceConfigured: true,
});
});
@@ -98,22 +105,29 @@ describe("AI organization service", () => {
test("fails closed when the organization is not entitled to AI", async () => {
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
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")).rejects.toThrow(OperationNotAllowedError);
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")).rejects.toThrow(OperationNotAllowedError);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
});
test("generates organization AI text with the configured package abstraction", async () => {
@@ -122,6 +136,7 @@ describe("AI organization service", () => {
const result = await generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
});
@@ -145,12 +160,14 @@ describe("AI organization service", () => {
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,
@@ -159,11 +176,46 @@ describe("AI organization service", () => {
);
});
describe("getAIDataAnalysisUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
test("returns undefined when all checks pass", () => {
expect(getAIDataAnalysisUnavailableReason(baseConfig)).toBeUndefined();
});
test("returns not_in_plan when not entitled", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEntitled: false })).toBe(
"not_in_plan"
);
});
test("returns not_enabled when disabled at org level", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEnabled: false })).toBe(
"not_enabled"
);
});
test("returns instance_not_configured when instance AI is missing", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
"instance_not_configured"
);
});
});
describe("getAISmartToolsUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
@@ -188,5 +240,15 @@ describe("AI organization service", () => {
"instance_not_configured"
);
});
test("ignores data-analysis flags (smart tools is independent of data analysis state)", () => {
expect(
getAISmartToolsUnavailableReason({
...baseConfig,
isAIDataAnalysisEntitled: false,
isAIDataAnalysisEnabled: false,
})
).toBeUndefined();
});
});
});
+33 -6
View File
@@ -4,11 +4,12 @@ import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export const AI_ERROR_CODES = {
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
} as const;
@@ -17,7 +18,9 @@ export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
isAISmartToolsEntitled: boolean;
isAIDataAnalysisEntitled: boolean;
isInstanceConfigured: boolean;
}
@@ -30,18 +33,32 @@ export const getOrganizationAIConfig = async (organizationId: string): Promise<T
throw new ResourceNotFoundError("Organization", organizationId);
}
const isAISmartToolsEntitled = await getIsAISmartToolsEnabled(organizationId);
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
getIsAISmartToolsEnabled(organizationId),
getIsAIDataAnalysisEnabled(organizationId),
]);
return {
organizationId,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
isAISmartToolsEntitled,
isAIDataAnalysisEntitled,
isInstanceConfigured: isInstanceAIConfigured(),
};
};
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export const getAIDataAnalysisUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
if (!aiConfig.isAIDataAnalysisEntitled) return "not_in_plan";
if (!aiConfig.isAIDataAnalysisEnabled) return "not_enabled";
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
return undefined;
};
export const getAISmartToolsUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
@@ -52,18 +69,25 @@ export const getAISmartToolsUnavailableReason = (
};
export const assertOrganizationAIConfigured = async (
organizationId: string
organizationId: string,
capability: "smartTools" | "dataAnalysis"
): Promise<TOrganizationAIConfig> => {
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!aiConfig.isAISmartToolsEntitled) {
if (!isCapabilityEntitled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
}
if (!aiConfig.isAISmartToolsEnabled) {
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
}
@@ -73,13 +97,15 @@ export const assertOrganizationAIConfigured = async (
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);
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
try {
return await generateText(options, env);
@@ -87,6 +113,7 @@ export const generateOrganizationAIText = async ({
logger.error(
{
organizationId,
capability,
isInstanceConfigured: aiConfig.isInstanceConfigured,
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
err: error,
+2 -1
View File
@@ -1,5 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
+1
View File
@@ -38,6 +38,7 @@ describe("auth", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+5 -26
View File
@@ -46,13 +46,6 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/hub/service", () => ({
deleteHubTenantData: vi.fn().mockResolvedValue({
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
error: null,
}),
}));
describe("Organization Service", () => {
beforeEach(() => {
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
@@ -80,6 +73,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -132,6 +126,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -184,6 +179,7 @@ describe("Organization Service", () => {
updatedAt: new Date(),
billing: expectedBilling,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -243,6 +239,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
workspaces: [
@@ -284,6 +281,7 @@ describe("Organization Service", () => {
usageCycleAnchor: expect.any(Date),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
@@ -357,7 +355,6 @@ describe("Organization Service", () => {
billing: { stripeCustomerId: "cus_123" },
memberships: [],
workspaces: [],
feedbackDirectories: [],
} as any);
await deleteOrganization("org1");
@@ -366,23 +363,5 @@ describe("Organization Service", () => {
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
}
});
test("should purge Hub-owned data for each feedback directory", async () => {
const { deleteHubTenantData } = await import("@/modules/hub/service");
vi.mocked(prisma.organization.delete).mockResolvedValue({
id: "org1",
name: "Test Org",
billing: null,
memberships: [],
workspaces: [],
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
} as any);
await deleteOrganization("org1");
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
});
+2 -13
View File
@@ -19,7 +19,6 @@ import { updateUser } from "@/lib/user/service";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getWorkspaces } from "@/lib/workspace/service";
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
import { deleteHubTenantData } from "@/modules/hub/service";
import { validateInputs } from "../utils/validate";
export const select = {
@@ -36,6 +35,7 @@ export const select = {
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -74,6 +74,7 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
@@ -293,11 +294,6 @@ export const deleteOrganization = async (organizationId: string) => {
id: true,
},
},
feedbackDirectories: {
select: {
id: true,
},
},
},
});
@@ -305,13 +301,6 @@ export const deleteOrganization = async (organizationId: string) => {
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
await cleanupStripeCustomer(stripeCustomerId);
}
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) for each
// directory tenant. Failures are logged inside the gateway and do not roll back the
// local delete.
for (const directory of deletedOrganization.feedbackDirectories) {
await deleteHubTenantData(directory.id);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -228,6 +228,7 @@ export const mockOrganizationOutput: TOrganization = {
createdAt: currentDate,
updatedAt: currentDate,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
limits: {
+2
View File
@@ -67,6 +67,7 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
{
id: "org2",
@@ -84,6 +85,7 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
@@ -18,7 +18,6 @@ import {
ValidationError,
isExpectedError,
} from "@formbricks/types/errors";
import { RequestBodyTooLargeError } from "@/app/lib/api/request-body";
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
@@ -79,7 +78,6 @@ describe("isExpectedError (shared helper)", () => {
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
"RequestBodyTooLargeError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -99,7 +97,6 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
{ ErrorClass: RequestBodyTooLargeError, args: [2 * 1024 * 1024] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
@@ -38,6 +38,50 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p }));
const buffer = convertToXlsxBuffer(["name"], rows);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
payloads.forEach((p, i) => {
const cell = sheet[`A${i + 2}`]; // row 1 is header
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${p}`);
});
});
test("should defang formula injection in xlsx header names", () => {
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const headerCell = sheet["A1"];
expect(headerCell.f).toBeUndefined();
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",
"attribute_key_required": "Schlüssel ist erforderlich",
"attribute_key_reserved_future_default": "Der Schlüssel ist für zukünftige Standardattribute reserviert ({reservedKeys}). Bitte wähle einen anderen Schlüssel.",
"attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen",
"attribute_label": "Bezeichnung",
"attribute_label_placeholder": "z. B. Geburtsdatum",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Persönlichen Link erstellen",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu erstellen.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_csv_reserved_column_names": "Reservierte CSV-Spaltennamen: {columns}. Diese Namen sind für zukünftige Standardattribute ({reservedKeys}) reserviert und können nicht als neue Attribute erstellt werden.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_activity_yet": "Noch keine Aktivität",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
},
"general": {
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
"ai_data_analysis_enabled_description": "KI nutzen, um mehr aus deinen Daten herauszuholen richte Dashboards, Diagramme, Berichte und mehr ein. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks KI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte deine:n Administrator:in, AI_PROVIDER, AI_MODEL und die passenden Provider-Zugangsdaten zu setzen, bevor du KI-Funktionen aktivierst.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "„Umfrage geschlossen“-Nachricht anpassen",
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_theme_in_look_and_feel_settings": "Passe das Theme in den <lookFeelLink>Look & Feel</lookFeelLink> Einstellungen an.",
"ai_data_analysis_disabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_features_not_enabled": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
"attribute_key_required": "Key is required",
"attribute_key_reserved_future_default": "Key is reserved for future default attributes ({reservedKeys}). Please choose a different key.",
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
"attribute_label": "Label",
"attribute_label_placeholder": "e.g. Date of Birth",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
"invalid_csv_reserved_column_names": "Reserved CSV column name(s): {columns}. These names are reserved for future default attributes ({reservedKeys}) and cannot be created as new attributes.",
"invalid_date_format": "Invalid date format. Please use a valid date.",
"invalid_number_format": "Invalid number format. Please enter a valid number.",
"no_activity_yet": "No activity yet",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Workspaces being granted access"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_theme_in_look_and_feel_settings": "Adjust the theme in the <lookFeelLink>Look & Feel</lookFeelLink> Settings.",
"ai_data_analysis_disabled": "AI data analysis is disabled for this organization.",
"ai_features_not_enabled": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",
"attribute_key_required": "La clave es obligatoria",
"attribute_key_reserved_future_default": "La clave está reservada para atributos predeterminados futuros ({reservedKeys}). Por favor, elige una clave diferente.",
"attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generar enlace personal",
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
"invalid_csv_reserved_column_names": "Nombre(s) de columna CSV reservado(s): {columns}. Estos nombres están reservados para atributos predeterminados futuros ({reservedKeys}) y no se pueden crear como nuevos atributos.",
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
"no_activity_yet": "Aún no hay actividad",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
"ai_data_analysis_disabled": "El análisis de datos con IA está deshabilitado para esta organización.",
"ai_features_not_enabled": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",
"attribute_key_required": "La clé est requise",
"attribute_key_reserved_future_default": "La clé est réservée pour les attributs par défaut futurs ({reservedKeys}). Veuillez choisir une clé différente.",
"attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr: uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre",
"attribute_label": "Étiquette",
"attribute_label_placeholder": "ex. Date de naissance",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
"invalid_csv_reserved_column_names": "Nom(s) de colonne CSV réservé(s) : {columns}. Ces noms sont réservés pour les attributs par défaut futurs ({reservedKeys}) et ne peuvent pas être créés en tant que nouveaux attributs.",
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
"no_activity_yet": "Aucune activité pour le moment",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Espaces de travail en cours d'ajout"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
"adjust_theme_in_look_and_feel_settings": "Ajuste le thème dans les paramètres <lookFeelLink>Apparence et ressenti</lookFeelLink>.",
"ai_data_analysis_disabled": "L'analyse de données par IA est désactivée pour cette organisation.",
"ai_features_not_enabled": "Les fonctionnalités IA ne sont pas activées pour cette organisation.",
"ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.",
"ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók. Betűvel kell kezdődnie.",
"attribute_key_placeholder": "például: szuletesi_ido",
"attribute_key_required": "A kulcs kötelező",
"attribute_key_reserved_future_default": "A kulcs le van foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}). Kérem, válasszon egy másik kulcsot.",
"attribute_key_safe_identifier_required": "A kulcs csak biztonságos azonosító lehet: csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók, és betűvel kell kezdődnie",
"attribute_label": "Címke",
"attribute_label_placeholder": "például: Születési idő",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Személyes hivatkozás előállítása",
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
"invalid_csv_reserved_column_names": "Fenntartott CSV oszlopnév/nevek: {columns}. Ezek a nevek le vannak foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}), és nem hozhatók létre új attribútumokként.",
"invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.",
"no_activity_yet": "Még nincs tevékenység",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
"ai_data_analysis_disabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
"ai_features_not_enabled": "Az AI funkciók nincsenek engedélyezve ezen szervezet számára.",
"ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.",
"ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth",
"attribute_key_required": "キーは必須です",
"attribute_key_reserved_future_default": "このキーは将来のデフォルト属性用に予約されています({reservedKeys})。別のキーを選択してください。",
"attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります",
"attribute_label": "ラベル",
"attribute_label_placeholder": "例: 生年月日",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
"invalid_csv_reserved_column_names": "予約されたCSV列名: {columns}。これらの名前は将来のデフォルト属性({reservedKeys})用に予約されており、新しい属性として作成できません。",
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
"no_activity_yet": "まだアクティビティがありません",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "アクセス権が付与されるワークスペース"
},
"general": {
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "この組織のAI機能を管理します。",
"ai_instance_not_configured": "AI は環境変数を使ってインスタンスレベルで設定されます。AI 機能を有効にする前に、管理者に AI_PROVIDER、このプロバイダーの認証情報、および対応するモデル一覧を設定してもらってください。",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "「フォームはクローズしました」メッセージを調整",
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
"ai_data_analysis_disabled": "この組織ではAIデータ分析が無効になっています。",
"ai_features_not_enabled": "この組織ではAI機能が有効になっていません。",
"ai_instance_not_configured": "AIが設定されていません。管理者にお問い合わせください。",
"ai_smart_tools_disabled": "この組織ではAIスマートツールが無効になっています。",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",
"attribute_key_required": "Sleutel is verplicht",
"attribute_key_reserved_future_default": "Sleutel is gereserveerd voor toekomstige standaardattributen ({reservedKeys}). Kies een andere sleutel.",
"attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter",
"attribute_label": "Label",
"attribute_label_placeholder": "bijv. Geboortedatum",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Persoonlijke link genereren",
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
"invalid_csv_reserved_column_names": "Gereserveerde CSV-kolomnaam/namen: {columns}. Deze namen zijn gereserveerd voor toekomstige standaardattributen ({reservedKeys}) en kunnen niet als nieuwe attributen worden aangemaakt.",
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
"no_activity_yet": "Nog geen activiteit",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Werkruimtes die toegang krijgen"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Pas het bericht 'Enquête gesloten' aan",
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
"ai_data_analysis_disabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
"ai_features_not_enabled": "AI-functies zijn niet ingeschakeld voor deze organisatie.",
"ai_instance_not_configured": "AI is niet geconfigureerd. Neem contact op met je beheerder.",
"ai_smart_tools_disabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolha uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex: Data de nascimento",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Esses nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
"no_activity_yet": "Nenhuma atividade ainda",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Workspaces recebendo acesso"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Ajustar mensagem 'Pesquisa Encerrada''",
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
"adjust_theme_in_look_and_feel_settings": "Ajuste o tema nas configurações de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desabilitada para esta organização.",
"ai_features_not_enabled": "Os recursos de IA não estão habilitados para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Entre em contato com seu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolhe uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex. Data de nascimento",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Estes nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
"no_activity_yet": "Ainda sem atividade",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Workspaces a receber acesso"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Ajustar mensagem de 'Inquérito Fechado'",
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
"adjust_theme_in_look_and_feel_settings": "Ajusta o tema nas definições de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desativada para esta organização.",
"ai_features_not_enabled": "As funcionalidades de IA não estão ativadas para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Contacta o teu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",
"attribute_key_required": "Cheia este obligatorie",
"attribute_key_reserved_future_default": "Cheia este rezervată pentru atribute implicite viitoare ({reservedKeys}). Te rugăm să alegi o cheie diferită.",
"attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă",
"attribute_label": "Etichetă",
"attribute_label_placeholder": "ex: Data nașterii",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generează link personal",
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
"invalid_csv_reserved_column_names": "Nume de coloană CSV rezervate: {columns}. Aceste nume sunt rezervate pentru atribute implicite viitoare ({reservedKeys}) și nu pot fi create ca atribute noi.",
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
"no_activity_yet": "Nicio activitate încă",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'",
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
"adjust_theme_in_look_and_feel_settings": "Ajustează tema în setările <lookFeelLink>Aspect și Experiență</lookFeelLink>.",
"ai_data_analysis_disabled": "Analiza de date AI este dezactivată pentru această organizație.",
"ai_features_not_enabled": "Funcțiile AI nu sunt activate pentru această organizație.",
"ai_instance_not_configured": "AI nu este configurat. Contactează administratorul.",
"ai_smart_tools_disabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth",
"attribute_key_required": "Ключ обязателен",
"attribute_key_reserved_future_default": "Ключ зарезервирован для будущих атрибутов по умолчанию ({reservedKeys}). Пожалуйста, выбери другой ключ.",
"attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы",
"attribute_label": "Метка",
"attribute_label_placeholder": "например, дата рождения",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Сгенерировать персональную ссылку",
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
"invalid_csv_reserved_column_names": "Зарезервированные названия столбцов CSV: {columns}. Эти названия зарезервированы для будущих атрибутов по умолчанию ({reservedKeys}) и не могут быть созданы как новые атрибуты.",
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
"no_activity_yet": "Пока нет активности",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
},
"general": {
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
"ai_instance_not_configured": "ИИ настраивается на уровне инстанса через переменные окружения. Попросите администратора настроить AI_PROVIDER, учетные данные этого провайдера и соответствующий список моделей перед включением функций ИИ.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Изменить сообщение «Опрос закрыт»",
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
"ai_data_analysis_disabled": "Анализ данных с помощью ИИ отключён для этой организации.",
"ai_features_not_enabled": "Функции ИИ не включены для этой организации.",
"ai_instance_not_configured": "ИИ не настроен. Свяжись с администратором.",
"ai_smart_tools_disabled": "Умные инструменты ИИ отключены для этой организации.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",
"attribute_key_required": "Nyckel krävs",
"attribute_key_reserved_future_default": "Nyckeln är reserverad för framtida standardattribut ({reservedKeys}). Välj en annan nyckel.",
"attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav",
"attribute_label": "Etikett",
"attribute_label_placeholder": "t.ex. Födelsedatum",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generera personlig länk",
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
"invalid_csv_reserved_column_names": "Reserverade CSV-kolumnnamn: {columns}. Dessa namn är reserverade för framtida standardattribut ({reservedKeys}) och kan inte skapas som nya attribut.",
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
"no_activity_yet": "Ingen aktivitet än",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
},
"general": {
"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_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.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "Justera meddelande för 'Enkät stängd'",
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
"ai_data_analysis_disabled": "AI-dataanalys är inaktiverad för den här organisationen.",
"ai_features_not_enabled": "AI-funktioner är inte aktiverade för den här organisationen.",
"ai_instance_not_configured": "AI är inte konfigurerad. Kontakta din administratör.",
"ai_smart_tools_disabled": "AI smarta verktyg är inaktiverade för den här organisationen.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Yalnızca küçük harfler, rakamlar ve alt çizgiler. Bir harfle başlamalıdır.",
"attribute_key_placeholder": "örn. dogum_tarihi",
"attribute_key_required": "Anahtar gereklidir",
"attribute_key_reserved_future_default": "Anahtar, gelecekteki varsayılan özellikler için ayrılmıştır ({reservedKeys}). Lütfen farklı bir anahtar seçin.",
"attribute_key_safe_identifier_required": "Anahtar güvenli bir tanımlayıcı olmalıdır: yalnızca küçük harfler, rakamlar ve alt çizgiler içermeli ve bir harfle başlamalıdır",
"attribute_label": "Etiket",
"attribute_label_placeholder": "örn. Doğum Tarihi",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Kişisel Bağlantı Oluştur",
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir anket seç.",
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harf, rakam ve alt çizgi içerebilir ve bir harfle başlamalıdır.",
"invalid_csv_reserved_column_names": "Ayrılmış CSV sütun adı/adları: {columns}. Bu adlar gelecekteki varsayılan özellikler ({reservedKeys}) için ayrılmıştır ve yeni özellik olarak oluşturulamaz.",
"invalid_date_format": "Geçersiz tarih formatı. Lütfen geçerli bir tarih kullanın.",
"invalid_number_format": "Geçersiz sayı formatı. Lütfen geçerli bir sayı girin.",
"no_activity_yet": "Henüz aktivite yok",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "Erişim verilen çalışma alanları"
},
"general": {
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
"ai_data_analysis_enabled_description": "Verilerinden daha fazlasını elde etmek, kontrol panelleri, grafikler, raporlar ve daha fazlasını kurmak için yapay zeka. Deneyim verilerine dokunur.",
"ai_enabled": "Formbricks Yapay Zeka",
"ai_enabled_description": "Bu organizasyon için yapay zeka destekli özellikleri yönet.",
"ai_instance_not_configured": "Yapay zeka, ortam değişkenleri aracılığıyla instance seviyesinde yapılandırılır. Yapay zeka özelliklerini etkinleştirmeden önce yöneticinden AI_PROVIDER, AI_MODEL ve eşleşen sağlayıcı kimlik bilgilerini ayarlamasını iste.",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "\"Anket Kapatıldı\" mesajını düzenle",
"adjust_survey_closed_message_description": "Anket kapalıyken ziyaretçilerin gördüğü mesajı değiştir.",
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
"ai_data_analysis_disabled": "Bu organizasyon için yapay zeka veri analizi devre dışı.",
"ai_features_not_enabled": "Bu organizasyon için yapay zeka özellikleri etkinleştirilmemiş.",
"ai_instance_not_configured": "Yapay zeka yapılandırılmamış. Yöneticinle iletişime geç.",
"ai_smart_tools_disabled": "Bu organizasyon için yapay zeka akıllı araçları devre dışı.",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "键为必填项",
"attribute_key_reserved_future_default": "该键已保留用于未来的默认属性({reservedKeys})。请选择其他键。",
"attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头",
"attribute_label": "标签",
"attribute_label_placeholder": "例如:出生日期",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
"invalid_csv_reserved_column_names": "CSV 列名已被保留:{columns}。这些名称已保留用于未来的默认属性({reservedKeys}),无法创建为新属性。",
"invalid_date_format": "日期格式无效。请使用有效日期。",
"invalid_number_format": "数字格式无效。请输入有效的数字。",
"no_activity_yet": "暂无活动",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "将被授权访问的工作区"
},
"general": {
"ai_data_analysis_enabled": "数据增强与分析(AI",
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
"ai_instance_not_configured": "AI 通过环境变量在实例级别进行配置。启用 AI 功能前,请让管理员设置 AI_PROVIDER、该提供商的凭据以及对应的模型列表。",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "调整 \"调查 关闭\" 消息",
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
"ai_data_analysis_disabled": "此组织已禁用 AI 数据分析。",
"ai_features_not_enabled": "此组织未启用 AI 功能。",
"ai_instance_not_configured": "AI 未配置。请联系您的管理员。",
"ai_smart_tools_disabled": "此组织已禁用 AI 智能工具。",
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "金鑰為必填項目",
"attribute_key_reserved_future_default": "此鍵已保留供未來預設屬性使用({reservedKeys})。請選擇其他鍵。",
"attribute_key_safe_identifier_required": "金鑰必須為安全識別字:僅限小寫字母、數字和底線,且必須以字母開頭",
"attribute_label": "標籤",
"attribute_label_placeholder": "例如:出生日期",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "產生個人連結",
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
"invalid_csv_reserved_column_names": "保留的 CSV 欄位名稱:{columns}。這些名稱已保留供未來預設屬性使用({reservedKeys}),無法建立為新屬性。",
"invalid_date_format": "日期格式無效。請使用有效的日期。",
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
"no_activity_yet": "尚無活動",
@@ -2612,6 +2610,8 @@
"workspaces_being_added": "正在授予存取權限的工作區"
},
"general": {
"ai_data_analysis_enabled": "資料增強與分析(AI",
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理此組織的 AI 功能。",
"ai_instance_not_configured": "AI 會透過環境變數在實例層級進行設定。啟用 AI 功能前,請管理員設定 AI_PROVIDER、該供應商的憑證,以及對應的模型清單。",
@@ -2818,6 +2818,7 @@
"adjust_survey_closed_message": "調整「問卷已關閉」訊息",
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外觀與感覺</lookFeelLink>設定中調整主題。",
"ai_data_analysis_disabled": "此組織已停用 AI 資料分析。",
"ai_features_not_enabled": "此組織未啟用 AI 功能。",
"ai_instance_not_configured": "AI 未設定。請聯絡您的管理員。",
"ai_smart_tools_disabled": "此組織已停用 AI 智慧工具。",
+2 -15
View File
@@ -1,7 +1,6 @@
import { ZodRawShape, z } from "zod";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
@@ -74,22 +73,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
let bodyData: Record<string, unknown>;
let bodyData;
try {
bodyData = await parseJsonBodyWithLimit<Record<string, unknown>>(request);
bodyData = await request.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return handleApiError(request, {
type: "payload_too_large",
details: [
{
field: "body",
issue: error.message,
},
],
});
}
logger.error({ error, url: request.url }, "Error parsing JSON input");
return handleApiError(request, {
type: "bad_request",
@@ -1,7 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok } from "@formbricks/types/error-handlers";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { handleApiError } from "@/modules/api/v2/lib/utils";
-30
View File
@@ -148,35 +148,6 @@ const conflictResponse = ({
);
};
const payloadTooLargeResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 413,
message: "Payload Too Large",
details,
},
},
{
status: 413,
headers,
}
);
};
const unprocessableEntityResponse = ({
details = [],
cors = false,
@@ -380,7 +351,6 @@ export const responses = {
forbiddenResponse,
notFoundResponse,
conflictResponse,
payloadTooLargeResponse,
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
-2
View File
@@ -28,8 +28,6 @@ export const handleApiError = (
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse({ details: err.details });
case "payload_too_large":
return responses.payloadTooLargeResponse({ details: err.details });
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":
@@ -10,10 +10,6 @@ import {
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceIds: string[], params: TGetContactAttributeKeysFilter) => {
@@ -49,13 +45,6 @@ export const createContactAttributeKey = async (
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { workspaceId, name, description, key, dataType } = contactAttributeKey;
if (isReservedFutureDefaultAttributeKey(key)) {
return err({
type: "bad_request",
details: [{ field: "key", issue: getReservedFutureDefaultAttributeKeyIssue([key]) }],
});
}
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
workspace: {
@@ -105,28 +105,6 @@ describe("createContactAttributeKey", () => {
}
});
test("returns bad request when key is reserved for future defaults", async () => {
const result = await createContactAttributeKey({
...inputContactAttributeKey,
key: "user_id",
});
expect(result.ok).toBe(false);
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "bad_request",
details: [
{
field: "key",
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
],
});
}
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
@@ -2,10 +2,6 @@ import { z } from "zod";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({})
.refine(
@@ -42,14 +38,6 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyInput",
@@ -77,14 +65,6 @@ export const ZContactAttributeKeyCreateInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyCreateInput",
+1 -7
View File
@@ -10,13 +10,7 @@ export type ApiErrorDetails = {
export type ApiErrorResponseV2 =
| {
type:
| "unauthorized"
| "forbidden"
| "conflict"
| "payload_too_large"
| "too_many_requests"
| "internal_server_error";
type: "unauthorized" | "forbidden" | "conflict" | "too_many_requests" | "internal_server_error";
details?: ApiErrorDetails;
}
| {
@@ -10,6 +10,7 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
SPAM_PROTECTION: "spam-protection",
CONTACTS: "contacts",
AI_SMART_TOOLS: "ai-smart-tools",
AI_DATA_ANALYSIS: "ai-data-analysis",
FEEDBACK_DIRECTORIES: "feedback-directories",
DASHBOARDS: "dashboards",
} as const;
@@ -81,7 +81,7 @@ export const translateSurveyFieldsAction = authenticatedActionClient
],
});
await assertOrganizationAIConfigured(organizationId);
await assertOrganizationAIConfigured(organizationId, "smartTools");
const translations = await translateFields({
organizationId,
@@ -40,6 +40,7 @@ Rules:
const result = await generateOrganizationAIText({
organizationId,
capability: "smartTools",
system: systemPrompt,
prompt: JSON.stringify(items),
});
@@ -363,7 +363,10 @@ export const generateAIChartAction = authenticatedActionClient
await checkDashboardsEnabled(organizationId);
await assertOrganizationAIConfigured(organizationId);
// Verify AI is entitled, enabled at org level, and configured at instance level.
// Uses "smartTools" (not "dataAnalysis") because chart generation only sends the
// Cube schema context and the user's prompt to the LLM — no response PII.
await assertOrganizationAIConfigured(organizationId, "smartTools");
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
@@ -0,0 +1,96 @@
import { describe, expect, test } from "vitest";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "./date-presets";
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
});
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
describe("expandPresetDateRanges", () => {
test("includes today for 'last 7 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
});
test("includes today for 'last 30 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
});
test("expands 'today' to today..today", () => {
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
});
test("expands 'yesterday' to yesterday..yesterday", () => {
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
});
test("'this month' runs from the 1st through today", () => {
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
});
test("'last month' is the full previous calendar month", () => {
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
});
test("'last month' handles year rollover", () => {
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
});
test("'this quarter' starts at the first day of the calendar quarter", () => {
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
});
test("'this year' starts on Jan 1", () => {
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
});
test("leaves explicit [start, end] tuple unchanged", () => {
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
});
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
});
test("returns input unchanged when there are no time dimensions", () => {
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
});
test("preserves other timeDimension fields (granularity, dimension)", () => {
const q: TChartQuery = {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
],
};
const result = expandPresetDateRanges(q, NOW);
expect(result.timeDimensions?.[0]).toMatchObject({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: ["2026-05-15", "2026-05-21"],
});
});
test("does not mutate the input query", () => {
const q = queryWithDateRange("last 7 days");
const before = JSON.stringify(q);
expandPresetDateRanges(q, NOW);
expect(JSON.stringify(q)).toBe(before);
});
});
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
@@ -6,6 +8,17 @@ import {
getFilterOperatorsForType,
} from "./schema-definition";
const chartCubeSchemaPath = fileURLToPath(
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
);
const dockerCubeSchemaPath = fileURLToPath(
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
);
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
describe("schema-definition", () => {
describe("getFilterOperatorsForType", () => {
test("returns string operators", () => {
@@ -94,5 +107,20 @@ describe("schema-definition", () => {
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
test("only exposes members present in the deployed Cube schema", () => {
const chartCubeSchema = readChartCubeSchema();
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
getCubeMemberName(id)
);
for (const member of exposedMembers) {
expect(chartCubeSchema).toContain(` ${member}: {`);
}
});
test("keeps the Helm and Docker Cube schemas in sync", () => {
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
});
});
});
+1 -6
View File
@@ -1,12 +1,11 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body";
import { webhookHandler } from "@/modules/ee/billing/api/lib/stripe-webhook";
export const POST = async (request: Request) => {
try {
const body = await readRequestBodyWithLimit(request);
const body = await request.text();
const requestHeaders = await headers(); // Corrected: headers() is async
const signature = requestHeaders.get("stripe-signature");
@@ -27,10 +26,6 @@ export const POST = async (request: Request) => {
return NextResponse.json(result.message || { received: true }, { status: 200 });
} catch (error: any) {
if (error instanceof RequestBodyTooLargeError) {
return NextResponse.json({ message: "Payload Too Large" }, { status: 413 });
}
logger.error(error, `Unhandled error in Stripe webhook POST handler: ${error.message}`);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
@@ -4,7 +4,6 @@ import { ZId } from "@formbricks/types/common";
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -28,11 +27,6 @@ const handleError = (err: unknown, url: string): { response: Response; error?: u
};
};
type TContactUserRequestBody = Record<string, unknown> & {
attributes?: Record<string, unknown>;
userId?: unknown;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -82,24 +76,7 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
let jsonInput: TContactUserRequestBody;
try {
jsonInput = await parseJsonBodyWithLimit<TContactUserRequestBody>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const jsonInput = await req.json();
// Basic input validation without Zod overhead
if (
@@ -114,13 +91,8 @@ export const POST = withV1ApiWrapper({
}
// Simple email validation if present (avoid Zod)
const attributes =
typeof jsonInput.attributes === "object" && jsonInput.attributes !== null
? jsonInput.attributes
: undefined;
if (attributes && Object.hasOwn(attributes, "email")) {
const email = attributes.email;
if (jsonInput.attributes?.email) {
const email = jsonInput.attributes.email;
if (typeof email !== "string" || !email.includes("@") || email.length < 3) {
return {
response: responses.badRequestResponse("Invalid email format", undefined, true),
@@ -128,7 +100,7 @@ export const POST = withV1ApiWrapper({
}
}
const userId = jsonInput.userId;
const { userId, attributes } = jsonInput;
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
@@ -3,12 +3,8 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import {
TContactAttributeKeyUpdateInput,
ZContactAttributeKeyUpdateInput,
@@ -60,10 +56,6 @@ export const updateContactAttributeKey = async (
): Promise<TContactAttributeKey | null> => {
validateInputs([contactAttributeKeyId, ZId], [data, ZContactAttributeKeyUpdateInput]);
if (data.key && isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
try {
const contactAttributeKey = await prisma.contactAttributeKey.update({
where: {
@@ -1,6 +1,5 @@
import { logger } from "@formbricks/logger";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -150,14 +149,8 @@ export const PUT = withV1ApiWrapper({
let contactAttributeKeyUpdate;
try {
contactAttributeKeyUpdate = await parseJsonBodyWithLimit(req);
contactAttributeKeyUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,21 +1,12 @@
import { z } from "zod";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const ZContactAttributeKeyCreateInput = z.object({
key: z
.string()
.refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
}),
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
description: z.string().optional(),
type: z.enum(["custom"]),
dataType: ZContactAttributeDataType.optional(),
@@ -33,9 +24,6 @@ export const ZContactAttributeKeyUpdateInput = z.object({
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
})
.optional(),
dataType: ZContactAttributeDataType.optional(),
});
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { createContactAttributeKey, getContactAttributeKeys } from "./contact-attribute-keys";
@@ -144,17 +144,6 @@ describe("createContactAttributeKey", () => {
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when key is reserved for future defaults", async () => {
await expect(
createContactAttributeKey(workspaceId, {
...createInput,
key: "user_id",
})
).rejects.toThrow(InvalidInputError);
expect(prisma.contactAttributeKey.count).not.toHaveBeenCalled();
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("should throw DatabaseError if Prisma create fails", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
const errorMessage = "Prisma create error";
@@ -3,14 +3,10 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceIds: string[]): Promise<TContactAttributeKey[]> => {
@@ -33,10 +29,6 @@ export const createContactAttributeKey = async (
workspaceId: string,
data: TContactAttributeKeyCreateInput
): Promise<TContactAttributeKey | null> => {
if (isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
where: {
workspaceId,
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -64,14 +63,8 @@ export const POST = withV1ApiWrapper({
let contactAttributeKeyInput;
try {
contactAttributeKeyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
contactAttributeKeyInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -6,10 +6,6 @@ import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-k
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
@@ -549,22 +545,6 @@ export const upsertBulkContacts = async (
});
}
const reservedNewKeys = attributeKeys.filter(
(key) => !existingKeySet.has(key) && isReservedFutureDefaultAttributeKey(key)
);
if (reservedNewKeys.length > 0) {
return err({
type: "bad_request",
details: [
{
field: "attributes",
issue: getReservedFutureDefaultAttributeKeyIssue(reservedNewKeys),
},
],
});
}
// Type Detection Phase
const attributeValuesByKey = buildAttributeValuesByKey(contacts);
const attributeTypeMap = determineAttributeTypes(attributeValuesByKey, existingAttributeKeys);
@@ -347,42 +347,6 @@ describe("upsertBulkContacts", () => {
expect(prisma.$executeRaw).toHaveBeenCalled();
});
test("should return bad request when payload creates reserved future default keys", async () => {
const mockContacts = [
{
attributes: [
{ attributeKey: { key: "email", name: "Email" }, value: "john@example.com" },
{ attributeKey: { key: "user_id", name: "User Id" }, value: "user-123" },
],
},
];
const mockParsedEmails = ["john@example.com"];
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce([
{ id: "attr-key-email", key: "email", workspaceId: mockWorkspaceId, name: "Email" },
] as any);
const result = await upsertBulkContacts(mockContacts, mockWorkspaceId, mockParsedEmails);
expect(result.ok).toBe(false);
expect(prisma.contact.createMany).not.toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "bad_request",
details: [
{
field: "attributes",
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
],
});
}
});
test("should update attribute key names when they change", async () => {
// Mock data: a contact with an attribute that has a new name for an existing key
const mockContacts = [
@@ -10,10 +10,6 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import {
createContactAttributeKey,
deleteContactAttributeKey,
@@ -23,15 +19,10 @@ import {
const ZCreateContactAttributeKeyAction = z.object({
workspaceId: ZId,
key: z
.string()
.refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
}),
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
name: z.string().optional(),
description: z.string().optional(),
dataType: ZContactAttributeDataType.optional(),
@@ -8,10 +8,6 @@ import { useTranslation } from "react-i18next";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase, isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -97,14 +93,6 @@ export function CreateAttributeModal({ workspaceId }: Readonly<CreateAttributeMo
);
return false;
}
if (isReservedFutureDefaultAttributeKey(key)) {
setKeyError(
t("workspace.contacts.attribute_key_reserved_future_default", {
reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
})
);
return false;
}
setKeyError("");
return true;
};
@@ -10,37 +10,33 @@ export const CsvTable = ({ data }: CsvTableProps) => {
}
const columns = Object.keys(data[0]);
return (
<div className="w-full overflow-x-auto rounded-md">
<table className="w-max min-w-full border-separate border-spacing-0 text-left text-xs">
<thead>
<tr className="bg-slate-100">
{columns.map((header) => (
<th
key={header}
scope="col"
className="sticky top-0 z-10 min-w-[120px] border-b-2 border-slate-200 bg-slate-100 px-3 py-2 font-semibold">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="bg-white">
{columns.map((header) => (
<td
key={`${rowIndex}-${header}`}
className="min-w-[120px] border-b border-slate-200 px-3 py-2">
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
{row[header] ?? ""}
</span>
</td>
))}
</tr>
<div
className="sticky top-0 z-10 grid gap-2 border-b-2 border-slate-100 bg-slate-100 px-3 py-2 text-left"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, index) => (
<span
key={index}
className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold capitalize leading-tight">
{header.replace(/_/g, " ")}
</span>
))}
</div>
{data.map((row, rowIndex) => (
<div
key={rowIndex}
className="grid gap-2 border-b border-gray-200 bg-white px-3 py-2 text-left leading-tight last:border-b-0"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, colIndex) => (
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-xs">
{row[header]}
</span>
))}
</tbody>
</table>
</div>
))}
</div>
);
};

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