mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-25 03:10:22 -05:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 880385029d | |||
| 062a59efed | |||
| b577c151c8 | |||
| f39f12ec1c | |||
| 01fb38cff8 | |||
| 71e57947b9 | |||
| 5f6d6d53b2 | |||
| b79758ee49 | |||
| 25b0b89e86 | |||
| f228ce7eb6 | |||
| 67ae13a61a | |||
| 70e72ab0de | |||
| 07d1d918ba | |||
| a98a0a8e73 | |||
| a5a67a05de | |||
| 4876e107f8 | |||
| 5db616ac07 | |||
| fa1ccdb2c3 | |||
| 6183ab4744 | |||
| 431a3d8a76 | |||
| 91ab958379 | |||
| e5df832653 | |||
| 0909c38eb1 | |||
| 08b0d95295 | |||
| a49e989413 | |||
| 9445b2f482 | |||
| f07d832516 | |||
| e615c692a9 | |||
| a9a910d15c | |||
| c425e7aff4 | |||
| a83a54a24a | |||
| f7890eaec3 | |||
| 8cd3187eff | |||
| 83bccc7ded | |||
| 00aa6d5247 | |||
| 0657c94ee5 | |||
| a36cef2936 | |||
| 467af8b6ef | |||
| d0e057eac1 | |||
| ef1f5a2b12 | |||
| 770041923f | |||
| 3e66ff25a1 | |||
| c979909da9 | |||
| 010d96ebcd | |||
| a0b3054f4a | |||
| 02d3cd2af3 | |||
| 2ef4eb4345 | |||
| 093757b386 | |||
| 64f8746940 | |||
| 851616078a | |||
| 06d5313629 | |||
| 7834c21d39 | |||
| f98ca39035 | |||
| 48f928b1bf | |||
| f5dfb4739c | |||
| 5a1fc01388 | |||
| 77a39c13fa | |||
| 5a12539c75 | |||
| 74e0fba757 | |||
| d9c2756185 | |||
| 88ad5c8625 | |||
| 610beee7eb | |||
| db0e2bb105 | |||
| 419ceef413 |
@@ -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 */}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||
with:
|
||||
version: latest
|
||||
version: v3.15.4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
env:
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"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,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,275 +571,285 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<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 && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
)}
|
||||
{openWorkspaceLimitModal && (
|
||||
|
||||
@@ -374,7 +374,6 @@ export const SettingsSidebarContent = ({
|
||||
label: t("common.your_profile"),
|
||||
href: `${basePath}/account/profile`,
|
||||
icon: <UserCircleIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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);
|
||||
await props.params;
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -127,7 +128,13 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
return memberships;
|
||||
};
|
||||
|
||||
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => {
|
||||
const Page = async (props: {
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
+1
-1
@@ -26,8 +26,8 @@ import {
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
SmilePlusIcon,
|
||||
SmartphoneIcon,
|
||||
StarIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 PrismaClientKnownRequestError =>
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
|
||||
error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
|
||||
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,125 +10,52 @@ import {
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import {
|
||||
IntegrationOAuthStateError,
|
||||
consumeIntegrationOAuthState,
|
||||
getSafeOAuthCallbackError,
|
||||
} from "@/lib/oauth/integration-state";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const getGoogleSheetsRedirectUrl = (workspaceId: string) =>
|
||||
new URL(`/workspaces/${workspaceId}/settings/workspace/integrations/google-sheets`, WEBAPP_URL);
|
||||
|
||||
const getGoogleSheetsOAuthState = async (state: string | null, userId: string) => {
|
||||
try {
|
||||
return await consumeIntegrationOAuthState({
|
||||
provider: "googleSheets",
|
||||
userId,
|
||||
state,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof IntegrationOAuthStateError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const getGoogleSheetsOAuthClient = () => {
|
||||
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
||||
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
||||
|
||||
if (!client_id) {
|
||||
return { response: responses.internalServerErrorResponse("Google client id is missing") };
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
return { response: responses.internalServerErrorResponse("Google client secret is missing") };
|
||||
}
|
||||
|
||||
if (!redirect_uri) {
|
||||
return { response: responses.internalServerErrorResponse("Google redirect url is missing") };
|
||||
}
|
||||
|
||||
return { client: new google.auth.OAuth2(client_id, client_secret, redirect_uri) };
|
||||
};
|
||||
|
||||
const captureGoogleSheetsConnectedEvent = async (userId: string, workspaceId: string) => {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(userId, "integration_connected", {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
userId,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
const url = new URL(req.url);
|
||||
const state = url.searchParams.get("state");
|
||||
const workspaceId = url.searchParams.get("state");
|
||||
const code = url.searchParams.get("code");
|
||||
const error = url.searchParams.get("error");
|
||||
|
||||
if (!workspaceId) {
|
||||
return responses.badRequestResponse("Invalid workspaceId");
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const oauthState = await getGoogleSheetsOAuthState(state, session.user.id);
|
||||
if (!oauthState) {
|
||||
return responses.badRequestResponse("Invalid OAuth state");
|
||||
}
|
||||
|
||||
const workspaceId = oauthState.workspaceId;
|
||||
const canUserAccessWorkspace = await hasUserWorkspaceAccess(session.user.id, workspaceId);
|
||||
if (!canUserAccessWorkspace) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const redirectUrl = getGoogleSheetsRedirectUrl(workspaceId);
|
||||
|
||||
const safeError = getSafeOAuthCallbackError(error);
|
||||
if (safeError) {
|
||||
redirectUrl.searchParams.set("error", safeError);
|
||||
return Response.redirect(redirectUrl);
|
||||
}
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
|
||||
const oAuth2ClientResult = getGoogleSheetsOAuthClient();
|
||||
if ("response" in oAuth2ClientResult) {
|
||||
return oAuth2ClientResult.response;
|
||||
}
|
||||
const oAuth2Client = oAuth2ClientResult.client;
|
||||
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
||||
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
||||
if (!client_id) return responses.internalServerErrorResponse("Google client id is missing");
|
||||
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
|
||||
if (!code) {
|
||||
return Response.redirect(redirectUrl);
|
||||
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
|
||||
}
|
||||
|
||||
const token = await oAuth2Client.getToken(code);
|
||||
const key = token.res?.data;
|
||||
if (!key) {
|
||||
return Response.redirect(redirectUrl);
|
||||
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
|
||||
}
|
||||
|
||||
oAuth2Client.setCredentials({ access_token: key.access_token });
|
||||
@@ -154,10 +81,29 @@ export const GET = async (req: Request) => {
|
||||
};
|
||||
|
||||
const result = await createOrUpdateIntegration(workspaceId, googleSheetIntegration);
|
||||
if (!result) {
|
||||
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(session.user.id, "integration_connected", {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
|
||||
}
|
||||
|
||||
await captureGoogleSheetsConnectedEvent(session.user.id, workspaceId);
|
||||
return Response.redirect(redirectUrl);
|
||||
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
|
||||
};
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { google } from "googleapis";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
} from "@/lib/constants";
|
||||
import { createIntegrationOAuthState } from "@/lib/oauth/integration-state";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
@@ -41,26 +39,12 @@ export const GET = async (req: NextRequest) => {
|
||||
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
let state: string;
|
||||
try {
|
||||
state = await createIntegrationOAuthState({
|
||||
provider: "googleSheets",
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, provider: "googleSheets", userId: session.user.id, workspaceId },
|
||||
"Failed to create Google Sheets OAuth state"
|
||||
);
|
||||
return responses.internalServerErrorResponse("Unable to start OAuth flow");
|
||||
}
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: scopes,
|
||||
prompt: "consent",
|
||||
state,
|
||||
state: workspaceId,
|
||||
});
|
||||
|
||||
return responses.successResponse({ authUrl });
|
||||
|
||||
@@ -313,18 +313,9 @@ describe("handleErrorResponse", () => {
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 404 notFound for ResourceNotFoundError", async () => {
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({
|
||||
code: "not_found",
|
||||
message: "Survey not found",
|
||||
details: {
|
||||
resource_id: "id-1",
|
||||
resource_type: "Survey",
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
|
||||
@@ -29,10 +29,11 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(error.resourceType, error.resourceId);
|
||||
}
|
||||
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
error instanceof ResourceNotFoundError
|
||||
) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Some error occurred");
|
||||
|
||||
+2
-30
@@ -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,34 +245,6 @@ 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(),
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -160,16 +155,6 @@ describe("createResponse", () => {
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["displayId"] },
|
||||
});
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("should throw original error on other Prisma errors", async () => {
|
||||
const genericError = new Error("Generic database error");
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
|
||||
@@ -2,12 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -16,7 +11,6 @@ import {
|
||||
isSingleUseIdUniqueConstraintError,
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { assertDisplayOwnership } from "@/lib/display/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
@@ -110,16 +104,6 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contact?.id ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(
|
||||
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
|
||||
contact,
|
||||
@@ -147,13 +131,6 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -5,11 +5,6 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import {
|
||||
IntegrationOAuthStateError,
|
||||
consumeIntegrationOAuthState,
|
||||
getSafeOAuthCallbackError,
|
||||
} from "@/lib/oauth/integration-state";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
@@ -26,97 +21,6 @@ const getEmail = async (token: string) => {
|
||||
return z.string().parse(res_?.email);
|
||||
};
|
||||
|
||||
const getSanitizedAirtableOAuthError = (error: unknown) => {
|
||||
if (!(error instanceof Error)) {
|
||||
return { message: "Unknown Airtable OAuth callback error" };
|
||||
}
|
||||
|
||||
const status = (error as { status?: unknown }).status;
|
||||
|
||||
return {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
...(typeof status === "number" ? { status } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const getAirtableOAuthState = async (state: string | null, userId: string) => {
|
||||
try {
|
||||
return await consumeIntegrationOAuthState({
|
||||
provider: "airtable",
|
||||
userId,
|
||||
state,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof IntegrationOAuthStateError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const captureAirtableConnectedEvent = async (userId: string, workspaceId: string) => {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(
|
||||
userId,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
|
||||
}
|
||||
};
|
||||
|
||||
const createAirtableIntegration = async ({
|
||||
clientId,
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
workspaceId,
|
||||
}: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
const key = await fetchAirtableAuthToken({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return responses.notFoundResponse("airtable auth token", key);
|
||||
}
|
||||
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
|
||||
const existingIntegration = await getIntegrationByType(workspaceId, "airtable");
|
||||
const existingData = existingIntegration?.config?.data ?? [];
|
||||
|
||||
await createOrUpdateIntegration(workspaceId, {
|
||||
type: "airtable" as const,
|
||||
config: {
|
||||
key,
|
||||
data: existingData,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
if (!authentication || !("user" in authentication)) {
|
||||
@@ -125,22 +29,18 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const state = queryParams.get("state");
|
||||
const workspaceId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
|
||||
const oauthState = await getAirtableOAuthState(state, authentication.user.id);
|
||||
if (!oauthState) {
|
||||
if (!workspaceId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid OAuth state"),
|
||||
response: responses.badRequestResponse("Invalid workspaceId"),
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceId = oauthState.workspaceId;
|
||||
const codeVerifier = oauthState.pkceCodeVerifier;
|
||||
if (!workspaceId || !codeVerifier) {
|
||||
if (!code) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid OAuth state"),
|
||||
response: responses.badRequestResponse("`code` is missing"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,55 +52,72 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const redirectUrl = new URL(`${basePath}/integrations/airtable`, WEBAPP_URL);
|
||||
const safeError = getSafeOAuthCallbackError(error);
|
||||
|
||||
if (!code && safeError) {
|
||||
redirectUrl.searchParams.set("error", safeError);
|
||||
return {
|
||||
response: Response.redirect(redirectUrl),
|
||||
};
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` is missing"),
|
||||
};
|
||||
}
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
const code_verifier = Buffer.from(workspaceId + authentication.user.id + workspaceId).toString("base64");
|
||||
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Airtable client id is missing"),
|
||||
};
|
||||
|
||||
try {
|
||||
const integrationErrorResponse = await createAirtableIntegration({
|
||||
clientId: client_id,
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri: redirect_uri,
|
||||
workspaceId,
|
||||
});
|
||||
const formData = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri,
|
||||
client_id,
|
||||
code_verifier,
|
||||
};
|
||||
|
||||
if (integrationErrorResponse) {
|
||||
return { response: integrationErrorResponse };
|
||||
try {
|
||||
const key = await fetchAirtableAuthToken(formData);
|
||||
if (!key) {
|
||||
return {
|
||||
response: responses.notFoundResponse("airtable auth token", key),
|
||||
};
|
||||
}
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
|
||||
const existingIntegration = await getIntegrationByType(workspaceId, "airtable");
|
||||
const existingData = existingIntegration?.config?.data ?? [];
|
||||
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as const,
|
||||
config: {
|
||||
key,
|
||||
data: existingData,
|
||||
email,
|
||||
},
|
||||
};
|
||||
await createOrUpdateIntegration(workspaceId, airtableIntegrationInput);
|
||||
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
|
||||
}
|
||||
|
||||
await captureAirtableConnectedEvent(authentication.user.id, workspaceId);
|
||||
|
||||
return {
|
||||
response: Response.redirect(redirectUrl),
|
||||
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/airtable`),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: getSanitizedAirtableOAuthError(error) },
|
||||
"Error in GET /api/v1/integrations/airtable/callback"
|
||||
);
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Unable to complete Airtable OAuth flow"),
|
||||
response: responses.internalServerErrorResponse(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { createIntegrationOAuthState, generatePkcePair } from "@/lib/oauth/integration-state";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
|
||||
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
|
||||
@@ -33,19 +33,22 @@ export const GET = withV1ApiWrapper({
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Airtable client id is missing"),
|
||||
};
|
||||
const { codeChallenge, codeChallengeMethod, codeVerifier } = generatePkcePair();
|
||||
const state = await createIntegrationOAuthState({
|
||||
provider: "airtable",
|
||||
userId: authentication.user.id,
|
||||
workspaceId,
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
});
|
||||
const codeVerifier = Buffer.from(workspaceId + authentication.user.id + workspaceId).toString("base64");
|
||||
|
||||
const codeChallengeMethod = "S256";
|
||||
const codeChallenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
|
||||
.digest("base64") // base64 encode, needs to be transformed to base64url
|
||||
.replace(/=/g, "") // remove =
|
||||
.replace(/\+/g, "-") // replace + with -
|
||||
.replace(/\//g, "_"); // replace / with _ now base64url encoded
|
||||
|
||||
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
|
||||
|
||||
authUrl.searchParams.append("client_id", client_id);
|
||||
authUrl.searchParams.append("redirect_uri", redirect_uri);
|
||||
authUrl.searchParams.append("state", state);
|
||||
authUrl.searchParams.append("state", workspaceId);
|
||||
authUrl.searchParams.append("scope", scope);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
|
||||
|
||||
@@ -11,11 +11,6 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import {
|
||||
IntegrationOAuthStateError,
|
||||
consumeIntegrationOAuthState,
|
||||
getSafeOAuthCallbackError,
|
||||
} from "@/lib/oauth/integration-state";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
@@ -28,28 +23,10 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const state = queryParams.get("state");
|
||||
const workspaceId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
|
||||
let oauthState;
|
||||
try {
|
||||
oauthState = await consumeIntegrationOAuthState({
|
||||
provider: "notion",
|
||||
userId: authentication.user.id,
|
||||
state,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof IntegrationOAuthStateError) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid OAuth state"),
|
||||
};
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const workspaceId = oauthState.workspaceId;
|
||||
if (!workspaceId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid workspaceId"),
|
||||
@@ -64,8 +41,6 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const redirectUrl = new URL(`${basePath}/integrations/notion`, WEBAPP_URL);
|
||||
const safeError = getSafeOAuthCallbackError(error);
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
@@ -73,13 +48,6 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
if (!code && safeError) {
|
||||
redirectUrl.searchParams.set("error", safeError);
|
||||
return {
|
||||
response: Response.redirect(redirectUrl),
|
||||
};
|
||||
}
|
||||
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
@@ -150,9 +118,13 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(redirectUrl),
|
||||
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/notion`),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/notion?error=${error}`),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
NOTION_OAUTH_CLIENT_SECRET,
|
||||
NOTION_REDIRECT_URI,
|
||||
} from "@/lib/constants";
|
||||
import { createIntegrationOAuthState } from "@/lib/oauth/integration-state";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -50,16 +49,9 @@ export const GET = withV1ApiWrapper({
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion auth url is missing"),
|
||||
};
|
||||
const state = await createIntegrationOAuthState({
|
||||
provider: "notion",
|
||||
userId: authentication.user.id,
|
||||
workspaceId,
|
||||
});
|
||||
const authUrlWithState = new URL(auth_url);
|
||||
authUrlWithState.searchParams.set("state", state);
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: authUrlWithState.toString() }),
|
||||
response: responses.successResponse({ authUrl: `${auth_url}&state=${workspaceId}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,11 +8,6 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import {
|
||||
IntegrationOAuthStateError,
|
||||
consumeIntegrationOAuthState,
|
||||
getSafeOAuthCallbackError,
|
||||
} from "@/lib/oauth/integration-state";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
@@ -25,28 +20,10 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const state = queryParams.get("state");
|
||||
const workspaceId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
|
||||
let oauthState;
|
||||
try {
|
||||
oauthState = await consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: authentication.user.id,
|
||||
state,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof IntegrationOAuthStateError) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid OAuth state"),
|
||||
};
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const workspaceId = oauthState.workspaceId;
|
||||
if (!workspaceId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid workspaceId"),
|
||||
@@ -61,8 +38,6 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const redirectUrl = new URL(`${basePath}/integrations/slack`, WEBAPP_URL);
|
||||
const safeError = getSafeOAuthCallbackError(error);
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
@@ -70,13 +45,6 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
if (!code && safeError) {
|
||||
redirectUrl.searchParams.set("error", safeError);
|
||||
return {
|
||||
response: Response.redirect(redirectUrl),
|
||||
};
|
||||
}
|
||||
|
||||
if (!SLACK_CLIENT_ID)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client id is missing"),
|
||||
@@ -157,9 +125,13 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(redirectUrl),
|
||||
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/slack`),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/slack?error=${error}`),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
|
||||
import { createIntegrationOAuthState } from "@/lib/oauth/integration-state";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -38,16 +37,9 @@ export const GET = withV1ApiWrapper({
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack auth url is missing"),
|
||||
};
|
||||
const state = await createIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: authentication.user.id,
|
||||
workspaceId,
|
||||
});
|
||||
const authUrl = new URL(SLACK_AUTH_URL);
|
||||
authUrl.searchParams.set("state", state);
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: authUrl.toString() }),
|
||||
response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${workspaceId}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -195,19 +190,7 @@ describe("createResponse V2", () => {
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["someOtherField"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
|
||||
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
@@ -216,7 +199,7 @@ describe("createResponse V2", () => {
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
|
||||
|
||||
@@ -2,12 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -17,7 +12,6 @@ import {
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
|
||||
import { assertDisplayOwnership } from "@/lib/display/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
@@ -105,16 +99,6 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contactId ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
@@ -138,13 +122,6 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -415,44 +415,6 @@ describe("withV3ApiWrapper", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns 413 problem response for oversized JSON input", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-payload-too-large",
|
||||
},
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: "payload_too_large",
|
||||
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
requestId: "req-payload-too-large",
|
||||
status: 413,
|
||||
title: "Payload Too Large",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid route params", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
|
||||
@@ -40,40 +40,6 @@ describe("parseAndValidateJsonBody", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a payload too large response when the request body exceeds the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
const result = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.issue).toBe("payload_too_large");
|
||||
expect(result.response.status).toBe(413);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "payload_too_large",
|
||||
message: "Payload Too Large",
|
||||
details: {
|
||||
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a validation response when the parsed JSON does not match the schema", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
|
||||
RequestBodyTooLargeError,
|
||||
parseJsonBodyWithLimit,
|
||||
readRequestBodyWithLimit,
|
||||
} from "./request-body";
|
||||
|
||||
const createStreamingRequest = (chunks: string[]): Request =>
|
||||
new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
describe("request body parsing", () => {
|
||||
test("rejects a request when content-length exceeds the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
|
||||
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
|
||||
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
|
||||
name: "RequestBodyTooLargeError",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
|
||||
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
|
||||
});
|
||||
|
||||
test("allows a body exactly at the body limit", async () => {
|
||||
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const body = await readRequestBodyWithLimit(request);
|
||||
|
||||
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
|
||||
expect(body).toBe(rawBody);
|
||||
});
|
||||
|
||||
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: "{invalid-json",
|
||||
});
|
||||
|
||||
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
|
||||
});
|
||||
|
||||
test("returns an empty string for requests without a body", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
@@ -213,7 +212,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const target = error.meta?.target;
|
||||
const targetFields = Array.isArray(target) ? (target as string[]) : [];
|
||||
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const selectDisplay = {
|
||||
@@ -146,58 +146,6 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplayForResponseValidation = async (
|
||||
displayId: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<{
|
||||
surveyId: string;
|
||||
workspaceId: string;
|
||||
responseId: string | null;
|
||||
contactId: string | null;
|
||||
} | null> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
const client = tx ?? prisma;
|
||||
try {
|
||||
const display = await client.display.findUnique({
|
||||
where: { id: displayId },
|
||||
select: {
|
||||
surveyId: true,
|
||||
contactId: true,
|
||||
response: { select: { id: true } },
|
||||
survey: { select: { workspaceId: true } },
|
||||
},
|
||||
});
|
||||
if (!display) return null;
|
||||
return {
|
||||
surveyId: display.surveyId,
|
||||
workspaceId: display.survey.workspaceId,
|
||||
responseId: display.response?.id ?? null,
|
||||
contactId: display.contactId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const assertDisplayOwnership = async (
|
||||
displayId: string,
|
||||
workspaceId: string,
|
||||
surveyId: string,
|
||||
contactId: string | null,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<void> => {
|
||||
const display = await getDisplayForResponseValidation(displayId, tx);
|
||||
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
|
||||
if (display.workspaceId !== workspaceId)
|
||||
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
|
||||
if (display.surveyId !== surveyId)
|
||||
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
|
||||
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
|
||||
if (display.contactId !== null && display.contactId !== contactId)
|
||||
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
|
||||
};
|
||||
|
||||
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
|
||||
@@ -3,18 +3,14 @@ import { prisma } from "@/lib/__mocks__/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
assertDisplayOwnership,
|
||||
getDisplayCountBySurveyId,
|
||||
getDisplayForResponseValidation,
|
||||
getDisplaysByContactId,
|
||||
getDisplaysBySurveyIdWithContact,
|
||||
} from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
|
||||
const mockResponseId = "clqnfg59i000208i426pb4wcv";
|
||||
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
@@ -294,96 +290,3 @@ describe("getDisplaysBySurveyIdWithContact", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mockDisplayRecord = {
|
||||
surveyId: mockSurveyId,
|
||||
contactId: null as string | null,
|
||||
response: null as { id: string } | null,
|
||||
survey: { workspaceId: mockWorkspaceId },
|
||||
};
|
||||
|
||||
describe("getDisplayForResponseValidation", () => {
|
||||
test("returns null when display is not found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
|
||||
const result = await getDisplayForResponseValidation(mockDisplayId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns mapped shape when display is found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: mockContactId,
|
||||
response: { id: mockResponseId },
|
||||
} as any);
|
||||
const result = await getDisplayForResponseValidation(mockDisplayId);
|
||||
expect(result).toEqual({
|
||||
surveyId: mockSurveyId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
responseId: mockResponseId,
|
||||
contactId: mockContactId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
})
|
||||
);
|
||||
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDisplayOwnership", () => {
|
||||
test("throws InvalidInputError when display is not found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
|
||||
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
|
||||
InvalidInputError
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when workspaceId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when surveyId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when display is already linked to a response", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
response: { id: mockResponseId },
|
||||
} as any);
|
||||
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
|
||||
InvalidInputError
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when contactId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: "contact-a",
|
||||
} as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("resolves without error when all ownership checks pass", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: mockContactId,
|
||||
} as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ErrorCode } from "@formbricks/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
import {
|
||||
IntegrationOAuthStateError,
|
||||
consumeIntegrationOAuthState,
|
||||
createIntegrationOAuthState,
|
||||
generatePkcePair,
|
||||
getSafeOAuthCallbackError,
|
||||
} from "./integration-state";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
getRedisClient: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCache = vi.mocked(cache);
|
||||
|
||||
const oauthStatePayload = {
|
||||
createdAt: Date.now(),
|
||||
provider: "slack",
|
||||
userId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
} as const;
|
||||
|
||||
const mockRedisConsume = (value: unknown) => {
|
||||
const evalMock = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
|
||||
mockCache.getRedisClient.mockResolvedValueOnce({ eval: evalMock } as any);
|
||||
return evalMock;
|
||||
};
|
||||
|
||||
describe("integration OAuth state", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
});
|
||||
|
||||
test("creates an opaque cached state that does not expose the workspace id", async () => {
|
||||
const state = await createIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
workspaceId: oauthStatePayload.workspaceId,
|
||||
});
|
||||
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
|
||||
expect(state).not.toContain(oauthStatePayload.workspaceId);
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
"fb:oauth:state:fake-hash",
|
||||
expect.objectContaining({
|
||||
provider: oauthStatePayload.provider,
|
||||
userId: oauthStatePayload.userId,
|
||||
workspaceId: oauthStatePayload.workspaceId,
|
||||
}),
|
||||
10 * 60 * 1000
|
||||
);
|
||||
});
|
||||
|
||||
test("stores the PKCE verifier with Airtable OAuth state", async () => {
|
||||
const pkceCodeVerifier = "E".repeat(43);
|
||||
|
||||
await createIntegrationOAuthState({
|
||||
pkceCodeVerifier,
|
||||
provider: "airtable",
|
||||
userId: oauthStatePayload.userId,
|
||||
workspaceId: oauthStatePayload.workspaceId,
|
||||
});
|
||||
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
"fb:oauth:state:fake-hash",
|
||||
expect.objectContaining({ pkceCodeVerifier }),
|
||||
10 * 60 * 1000
|
||||
);
|
||||
});
|
||||
|
||||
test("consumes a valid state atomically and returns the stored workspace", async () => {
|
||||
const state = await createIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
workspaceId: oauthStatePayload.workspaceId,
|
||||
});
|
||||
const redisEval = mockRedisConsume(oauthStatePayload);
|
||||
|
||||
const consumedState = await consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state,
|
||||
});
|
||||
|
||||
expect(consumedState).toEqual(oauthStatePayload);
|
||||
expect(redisEval).toHaveBeenCalledWith(expect.stringContaining('redis.call("GET", KEYS[1])'), {
|
||||
arguments: [],
|
||||
keys: ["fb:oauth:state:fake-hash"],
|
||||
});
|
||||
expect(redisEval).toHaveBeenCalledWith(expect.stringContaining('redis.call("DEL", KEYS[1])'), {
|
||||
arguments: [],
|
||||
keys: ["fb:oauth:state:fake-hash"],
|
||||
});
|
||||
|
||||
mockRedisConsume(null);
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state,
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
});
|
||||
|
||||
test("rejects reused or unknown states", async () => {
|
||||
mockRedisConsume(null);
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "A".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
});
|
||||
|
||||
test("rejects malformed callback state before reading Redis", async () => {
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "too-short",
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
|
||||
expect(mockCache.getRedisClient).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects wrong provider and wrong user states", async () => {
|
||||
mockRedisConsume(oauthStatePayload);
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "notion",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "B".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
|
||||
mockRedisConsume(oauthStatePayload);
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: "user-2",
|
||||
state: "C".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
});
|
||||
|
||||
test("fails closed when cache storage or Redis is unavailable", async () => {
|
||||
mockCache.set.mockResolvedValueOnce({ ok: false, error: { code: ErrorCode.RedisConnectionError } });
|
||||
|
||||
await expect(
|
||||
createIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
workspaceId: oauthStatePayload.workspaceId,
|
||||
})
|
||||
).rejects.toThrow("Unable to start OAuth flow");
|
||||
|
||||
mockCache.getRedisClient.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "D".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails closed when Redis client resolution throws", async () => {
|
||||
mockCache.getRedisClient.mockRejectedValueOnce(new Error("Redis unavailable"));
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "I".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects malformed cached state values", async () => {
|
||||
mockRedisConsume({
|
||||
createdAt: Date.now(),
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "F".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects unexpected cached value types", async () => {
|
||||
mockCache.getRedisClient.mockResolvedValueOnce({
|
||||
eval: vi.fn().mockResolvedValue(42),
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "G".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails closed when atomic cache consumption fails", async () => {
|
||||
mockCache.getRedisClient.mockResolvedValueOnce({
|
||||
eval: vi.fn().mockRejectedValue(new Error("Redis failed")),
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
consumeIntegrationOAuthState({
|
||||
provider: "slack",
|
||||
userId: oauthStatePayload.userId,
|
||||
state: "H".repeat(43),
|
||||
})
|
||||
).rejects.toThrow(IntegrationOAuthStateError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("generates an RFC 7636 S256 PKCE pair", () => {
|
||||
const { codeChallenge, codeChallengeMethod, codeVerifier } = generatePkcePair();
|
||||
|
||||
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
|
||||
expect(codeChallenge).toBe("fake-hash");
|
||||
expect(codeChallengeMethod).toBe("S256");
|
||||
});
|
||||
|
||||
test("sanitizes provider callback errors", () => {
|
||||
expect(getSafeOAuthCallbackError("access_denied")).toBe("access_denied");
|
||||
expect(getSafeOAuthCallbackError("https://evil.example")).toBe("oauth_error");
|
||||
expect(getSafeOAuthCallbackError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import "server-only";
|
||||
import crypto from "node:crypto";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
|
||||
const INTEGRATION_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const OAUTH_STATE_ENTROPY_BYTES = 32;
|
||||
const BASE64URL_TOKEN_REGEX = /^[A-Za-z0-9_-]{43,128}$/;
|
||||
const SAFE_OAUTH_CALLBACK_ERRORS = new Set([
|
||||
"access_denied",
|
||||
"invalid_request",
|
||||
"invalid_scope",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
]);
|
||||
|
||||
export type TIntegrationOAuthProvider = "googleSheets" | "slack" | "notion" | "airtable";
|
||||
|
||||
type TStoredIntegrationOAuthState = {
|
||||
provider: TIntegrationOAuthProvider;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
pkceCodeVerifier?: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type TCreateIntegrationOAuthStateInput = {
|
||||
provider: TIntegrationOAuthProvider;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
pkceCodeVerifier?: string;
|
||||
};
|
||||
|
||||
type TConsumeIntegrationOAuthStateInput = {
|
||||
provider: TIntegrationOAuthProvider;
|
||||
userId: string;
|
||||
state: string | null;
|
||||
};
|
||||
|
||||
export class IntegrationOAuthStateError extends Error {
|
||||
constructor(message = "Invalid OAuth state") {
|
||||
super(message);
|
||||
this.name = "IntegrationOAuthStateError";
|
||||
}
|
||||
}
|
||||
|
||||
const toBase64Url = (buffer: Buffer) =>
|
||||
buffer.toString("base64").replaceAll("=", "").replaceAll("+", "-").replaceAll("/", "_");
|
||||
|
||||
const generateRandomToken = () => toBase64Url(crypto.randomBytes(OAUTH_STATE_ENTROPY_BYTES));
|
||||
|
||||
const hashState = (state: string) => crypto.createHash("sha256").update(state).digest("hex");
|
||||
|
||||
const getIntegrationOAuthStateCacheKey = (stateHash: string) =>
|
||||
createCacheKey.custom("oauth", "state", stateHash);
|
||||
|
||||
const getValidToken = (token: string | undefined, label: string) => {
|
||||
if (!token || !BASE64URL_TOKEN_REGEX.test(token)) {
|
||||
throw new IntegrationOAuthStateError(`Invalid OAuth ${label}`);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const parseStoredIntegrationOAuthState = (serializedValue: string): TStoredIntegrationOAuthState => {
|
||||
try {
|
||||
const parsedValue = JSON.parse(serializedValue) as Partial<TStoredIntegrationOAuthState>;
|
||||
|
||||
if (
|
||||
!parsedValue ||
|
||||
typeof parsedValue.provider !== "string" ||
|
||||
typeof parsedValue.userId !== "string" ||
|
||||
typeof parsedValue.workspaceId !== "string" ||
|
||||
typeof parsedValue.createdAt !== "number" ||
|
||||
(parsedValue.pkceCodeVerifier !== undefined && typeof parsedValue.pkceCodeVerifier !== "string")
|
||||
) {
|
||||
throw new Error("Invalid stored OAuth state shape");
|
||||
}
|
||||
|
||||
return parsedValue as TStoredIntegrationOAuthState;
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to parse stored integration OAuth state");
|
||||
throw new IntegrationOAuthStateError();
|
||||
}
|
||||
};
|
||||
|
||||
const consumeCachedIntegrationOAuthState = async (
|
||||
cacheKey: string,
|
||||
logContext: Record<string, unknown>
|
||||
): Promise<TStoredIntegrationOAuthState | null> => {
|
||||
let redis;
|
||||
|
||||
try {
|
||||
redis = await cache.getRedisClient();
|
||||
} catch (error) {
|
||||
logger.error({ ...logContext, error }, "Failed to resolve Redis client for integration OAuth state");
|
||||
throw new IntegrationOAuthStateError("Unable to validate OAuth state");
|
||||
}
|
||||
|
||||
if (!redis) {
|
||||
logger.error({ ...logContext }, "Redis is required to validate integration OAuth state");
|
||||
throw new IntegrationOAuthStateError("Unable to validate OAuth state");
|
||||
}
|
||||
|
||||
try {
|
||||
const serializedValue = await redis.eval(
|
||||
`
|
||||
local value = redis.call("GET", KEYS[1])
|
||||
if value then
|
||||
redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return value
|
||||
`,
|
||||
{
|
||||
arguments: [],
|
||||
keys: [cacheKey],
|
||||
}
|
||||
);
|
||||
|
||||
if (serializedValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof serializedValue !== "string") {
|
||||
logger.error({ ...logContext }, "Unexpected cached integration OAuth state value");
|
||||
throw new IntegrationOAuthStateError();
|
||||
}
|
||||
|
||||
return parseStoredIntegrationOAuthState(serializedValue);
|
||||
} catch (error) {
|
||||
if (error instanceof IntegrationOAuthStateError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error({ ...logContext, error }, "Failed to consume integration OAuth state");
|
||||
throw new IntegrationOAuthStateError("Unable to validate OAuth state");
|
||||
}
|
||||
};
|
||||
|
||||
export const createIntegrationOAuthState = async ({
|
||||
provider,
|
||||
userId,
|
||||
workspaceId,
|
||||
pkceCodeVerifier,
|
||||
}: TCreateIntegrationOAuthStateInput): Promise<string> => {
|
||||
if (pkceCodeVerifier !== undefined) {
|
||||
getValidToken(pkceCodeVerifier, "PKCE verifier");
|
||||
}
|
||||
|
||||
const state = generateRandomToken();
|
||||
const stateHash = hashState(state);
|
||||
const cacheKey = getIntegrationOAuthStateCacheKey(stateHash);
|
||||
const storedState: TStoredIntegrationOAuthState = {
|
||||
provider,
|
||||
userId,
|
||||
workspaceId,
|
||||
pkceCodeVerifier,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const result = await cache.set(cacheKey, storedState, INTEGRATION_OAUTH_STATE_TTL_MS);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error({ error: result.error, provider, userId, workspaceId }, "Failed to store OAuth state");
|
||||
throw new Error("Unable to start OAuth flow");
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const consumeIntegrationOAuthState = async ({
|
||||
provider,
|
||||
userId,
|
||||
state,
|
||||
}: TConsumeIntegrationOAuthStateInput): Promise<TStoredIntegrationOAuthState> => {
|
||||
let providedState;
|
||||
|
||||
try {
|
||||
providedState = getValidToken(state ?? undefined, "state");
|
||||
} catch (error) {
|
||||
logger.warn({ provider, userId }, "Integration OAuth callback rejected due to malformed state");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const stateHash = hashState(providedState);
|
||||
const cacheKey = getIntegrationOAuthStateCacheKey(stateHash);
|
||||
const storedState = await consumeCachedIntegrationOAuthState(cacheKey, { provider, stateHash, userId });
|
||||
|
||||
if (storedState?.provider !== provider || storedState?.userId !== userId) {
|
||||
logger.warn({ provider, stateHash, userId }, "Integration OAuth callback rejected due to invalid state");
|
||||
throw new IntegrationOAuthStateError();
|
||||
}
|
||||
|
||||
return storedState;
|
||||
};
|
||||
|
||||
export const getSafeOAuthCallbackError = (error: string | null): string | null => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SAFE_OAUTH_CALLBACK_ERRORS.has(error) ? error : "oauth_error";
|
||||
};
|
||||
|
||||
export const generatePkcePair = () => {
|
||||
const verifier = generateRandomToken();
|
||||
const challenge = toBase64Url(crypto.createHash("sha256").update(verifier).digest());
|
||||
|
||||
return {
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: "S256" as const,
|
||||
codeVerifier: verifier,
|
||||
};
|
||||
};
|
||||
@@ -46,6 +46,13 @@ 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);
|
||||
@@ -350,6 +357,7 @@ describe("Organization Service", () => {
|
||||
billing: { stripeCustomerId: "cus_123" },
|
||||
memberships: [],
|
||||
workspaces: [],
|
||||
feedbackDirectories: [],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
@@ -358,5 +366,23 @@ 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ 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 = {
|
||||
@@ -292,6 +293,11 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
feedbackDirectories: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -299,6 +305,13 @@ 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);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { updateResponse } from "./service";
|
||||
@@ -325,35 +324,5 @@ describe("updateResponse", () => {
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -570,13 +569,6 @@ export const updateResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Response", responseId);
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,47 +38,6 @@ 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", () => {
|
||||
@@ -101,51 +60,4 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,42 +165,6 @@ describe("apiWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle oversized JSON input in request body", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 413 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||
type: "payload_too_large",
|
||||
details: [
|
||||
{
|
||||
field: "body",
|
||||
issue: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty body when body schema is provided", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
|
||||
@@ -85,18 +85,6 @@ describe("utils", () => {
|
||||
expect(body.error.message).toBe("Conflict");
|
||||
});
|
||||
|
||||
test('return payload too large response for "payload_too_large" error', async () => {
|
||||
const details = [{ field: "body", issue: "Request body must not exceed 2097152 bytes" }];
|
||||
const error: ApiErrorResponseV2 = { type: "payload_too_large", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(413);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(413);
|
||||
expect(body.error.message).toBe("Payload Too Large");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
|
||||
const details = [{ field: "data", issue: "malformed" }];
|
||||
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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,5 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
@@ -8,17 +6,6 @@ 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", () => {
|
||||
@@ -107,20 +94,5 @@ 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,57 @@ export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecor
|
||||
}
|
||||
};
|
||||
|
||||
export type HubTenantDataDeleteResult = {
|
||||
data: {
|
||||
deletedFeedbackRecords: number;
|
||||
deletedEmbeddings: number;
|
||||
deletedWebhooks: number;
|
||||
} | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
type TenantDataDeleteResponse = {
|
||||
tenant_id: string;
|
||||
deleted_feedback_records: number;
|
||||
deleted_embeddings: number;
|
||||
deleted_webhooks: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Purge all Hub-owned data (feedback records, derived embeddings, webhooks) for a tenant.
|
||||
* Called when the owning organization is deleted so Hub-side rows don't become orphaned.
|
||||
* Idempotent on the Hub side; the caller treats failures as best-effort.
|
||||
*
|
||||
* Hits `DELETE /v1/tenants/{tenant_id}/data` directly because the SDK doesn't yet expose
|
||||
* a typed method for this endpoint.
|
||||
*/
|
||||
export const deleteHubTenantData = async (tenantId: string): Promise<HubTenantDataDeleteResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.delete<TenantDataDeleteResponse>(
|
||||
`/v1/tenants/${encodeURIComponent(tenantId)}/data`
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
deletedFeedbackRecords: data.deleted_feedback_records,
|
||||
deletedEmbeddings: data.deleted_embeddings,
|
||||
deletedWebhooks: data.deleted_webhooks,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn({ err, tenantId }, "Hub: deleteHubTenantData failed");
|
||||
const status = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: HubError | null;
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,7 +123,11 @@ export const SurveyLoadingAnimation = ({
|
||||
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
|
||||
)}>
|
||||
{isBrandingEnabled && (
|
||||
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
|
||||
<Image
|
||||
src={Logo as string}
|
||||
alt="Logo"
|
||||
className={cn("w-32 transition-all duration-1000 md:w-40")}
|
||||
/>
|
||||
)}
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref as any}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
|
||||
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
|
||||
"build:dev": "pnpm run build",
|
||||
"start": "next start",
|
||||
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
|
||||
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
|
||||
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"test": "dotenv -e ../../.env -- vitest run",
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
import "@prisma/client";
|
||||
|
||||
declare module "@prisma/client" {
|
||||
namespace Prisma {
|
||||
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
|
||||
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"exclude": [
|
||||
"../../.env",
|
||||
".next",
|
||||
"node_modules",
|
||||
"playwright",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/tests/**",
|
||||
"**/__mocks__/**",
|
||||
"**/__tests__/**"
|
||||
],
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.d.ts",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"lib/**/*.tsx",
|
||||
"modules/**/*.ts",
|
||||
"modules/**/*.tsx",
|
||||
"scripts/**/*.ts",
|
||||
"../../packages/types/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -65,7 +65,6 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
|
||||
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
|
||||
|
||||
When the Formbricks migration job is enabled, Hub waits for the `formbricks-migration` Job to complete before its own goose/river init migrations run. This keeps fresh shared-database installs from creating Hub tables before Prisma has initialized the Formbricks schema.
|
||||
If the Job has already been cleaned up, Hub only continues after all expected Prisma and data migration success markers are present in the database.
|
||||
|
||||
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
|
||||
|
||||
@@ -130,7 +129,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| deployment.env | object | `{}` | |
|
||||
| deployment.envFrom | string | `nil` | |
|
||||
| deployment.image.digest | string | `""` | When set, takes precedence over tag. |
|
||||
| deployment.image.digest | string | `""` | |
|
||||
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
|
||||
| deployment.image.tag | string | `""` | |
|
||||
@@ -225,7 +224,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
| hub.migration.waitForFormbricksMigration.enabled | bool | `true` | |
|
||||
| hub.migration.waitForFormbricksMigration.intervalSeconds | int | `5` | |
|
||||
| hub.migration.waitForFormbricksMigration.maxAttempts | int | `180` | |
|
||||
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | Consecutive missing Job reads before using DB markers. |
|
||||
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | |
|
||||
| hub.pdb.enabled | bool | `false` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
{{ .Release.Name | camelcase }} with {{ include "formbricks.deploymentImage" . }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
|
||||
{{ .Release.Name | camelcase }} with {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
|
||||
|
||||
Here's how you can access and manage your deployment:
|
||||
---
|
||||
|
||||
@@ -125,6 +125,10 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.migrationJobName" -}}
|
||||
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.redisName" -}}
|
||||
{{- .Values.redis.fullnameOverride | default (printf "%s-redis" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
@@ -153,21 +157,6 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- .Values.redis.auth.existingSecretPasswordKey | default "REDIS_PASSWORD" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.migrationJobName" -}}
|
||||
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Formbricks application image reference. A configured digest takes precedence over the tag.
|
||||
*/}}
|
||||
{{- define "formbricks.deploymentImage" -}}
|
||||
{{- if .Values.deployment.image.digest -}}
|
||||
{{- printf "%s@%s" .Values.deployment.image.repository .Values.deployment.image.digest -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" .Values.deployment.image.repository (.Values.deployment.image.tag | default .Chart.AppVersion | default "latest") -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.hubSecretName" -}}
|
||||
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
|
||||
{{- end }}
|
||||
@@ -244,8 +233,9 @@ Embedding API key value for the generated embeddings secret.
|
||||
{{- $secretName := include "formbricks.hubEmbeddingsSecretName" . }}
|
||||
{{- $secretKey := .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
|
||||
{{- if and $secret (index $secret.data $secretKey) }}
|
||||
{{- index $secret.data $secretKey | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData $secretKey }}
|
||||
{{- index $secretData $secretKey | b64dec -}}
|
||||
{{- else if .Values.hub.embeddings.auth.apiKey }}
|
||||
{{- .Values.hub.embeddings.auth.apiKey -}}
|
||||
{{- else }}
|
||||
@@ -291,8 +281,9 @@ true
|
||||
|
||||
{{- define "formbricks.postgresAdminPassword" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if and $secret (index $secret.data "POSTGRES_ADMIN_PASSWORD") }}
|
||||
{{- index $secret.data "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData "POSTGRES_ADMIN_PASSWORD" }}
|
||||
{{- index $secretData "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 16 -}}
|
||||
{{- end -}}
|
||||
@@ -300,8 +291,9 @@ true
|
||||
|
||||
{{- define "formbricks.postgresUserPassword" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if and $secret (index $secret.data "POSTGRES_USER_PASSWORD") }}
|
||||
{{- index $secret.data "POSTGRES_USER_PASSWORD" | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData "POSTGRES_USER_PASSWORD" }}
|
||||
{{- index $secretData "POSTGRES_USER_PASSWORD" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 16 -}}
|
||||
{{- end -}}
|
||||
@@ -311,8 +303,9 @@ true
|
||||
{{- $redisSecretName := include "formbricks.redisSecretName" . }}
|
||||
{{- $redisSecretKey := include "formbricks.redisSecretKey" . }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $redisSecretName) }}
|
||||
{{- if and $secret (index $secret.data $redisSecretKey) }}
|
||||
{{- index $secret.data $redisSecretKey | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData $redisSecretKey }}
|
||||
{{- index $secretData $redisSecretKey | b64dec -}}
|
||||
{{- else if eq $redisSecretName (include "formbricks.appSecretName" .) }}
|
||||
{{- randAlphaNum 16 -}}
|
||||
{{- else }}
|
||||
@@ -322,9 +315,10 @@ true
|
||||
|
||||
{{- define "formbricks.cronSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if and $secret (index $secret.data "CRON_SECRET") }}
|
||||
{{- index $secret.data "CRON_SECRET" | b64dec -}}
|
||||
{{- else if $secret }}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData "CRON_SECRET" }}
|
||||
{{- index $secretData "CRON_SECRET" | b64dec -}}
|
||||
{{- else if and $secret (hasKey $secret "data") }}
|
||||
{{- fail (printf "Secret %q exists in namespace %q but is missing CRON_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
@@ -333,8 +327,11 @@ true
|
||||
|
||||
{{- define "formbricks.encryptionKey" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if $secret }}
|
||||
{{- index $secret.data "ENCRYPTION_KEY" | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData "ENCRYPTION_KEY" }}
|
||||
{{- index $secretData "ENCRYPTION_KEY" | b64dec -}}
|
||||
{{- else if and $secret (hasKey $secret "data") }}
|
||||
{{- fail (printf "Secret %q exists in namespace %q but is missing ENCRYPTION_KEY" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
@@ -342,8 +339,11 @@ true
|
||||
|
||||
{{- define "formbricks.nextAuthSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if $secret }}
|
||||
{{- index $secret.data "NEXTAUTH_SECRET" | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData "NEXTAUTH_SECRET" }}
|
||||
{{- index $secretData "NEXTAUTH_SECRET" | b64dec -}}
|
||||
{{- else if and $secret (hasKey $secret "data") }}
|
||||
{{- fail (printf "Secret %q exists in namespace %q but is missing NEXTAUTH_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
@@ -352,8 +352,9 @@ true
|
||||
{{- define "formbricks.hubApiKey" -}}
|
||||
{{- $hubSecretName := include "formbricks.hubSecretName" . }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $hubSecretName) }}
|
||||
{{- if and $secret (index $secret.data "HUB_API_KEY") }}
|
||||
{{- index $secret.data "HUB_API_KEY" | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData "HUB_API_KEY" }}
|
||||
{{- index $secretData "HUB_API_KEY" | b64dec -}}
|
||||
{{- else if .Values.hub.existingSecret }}
|
||||
{{- fail (printf "hub.existingSecret %q must already exist in namespace %q and contain HUB_API_KEY when rendering the generated app secret. Disable secret.enabled and provide app-secrets externally, or pre-create the Hub secret." $hubSecretName .Release.Namespace) -}}
|
||||
{{- else }}
|
||||
@@ -363,8 +364,9 @@ true
|
||||
|
||||
{{- define "formbricks.cubejsApiSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
|
||||
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
|
||||
{{- $secretData := dig "data" dict $secret }}
|
||||
{{- if index $secretData "CUBEJS_API_SECRET" }}
|
||||
{{- index $secretData "CUBEJS_API_SECRET" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
|
||||
@@ -79,7 +79,7 @@ spec:
|
||||
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds | default 30 }}
|
||||
containers:
|
||||
- name: {{ template "formbricks.name" . }}
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
{{- if .Values.deployment.command }}
|
||||
command:
|
||||
|
||||
@@ -39,35 +39,22 @@ spec:
|
||||
initContainers:
|
||||
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
|
||||
- name: wait-for-formbricks-migration
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
- -e
|
||||
- |
|
||||
const fs = require("fs");
|
||||
const fsp = fs.promises;
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const { pathToFileURL } = require("url");
|
||||
const { Prisma, PrismaClient } = require("@prisma/client");
|
||||
|
||||
const parsePositiveInteger = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
const maxAttempts = parsePositiveInteger(process.env.FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS, 180);
|
||||
const missingJobMaxAttempts = parsePositiveInteger(
|
||||
process.env.FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS,
|
||||
12
|
||||
);
|
||||
const intervalSeconds = parsePositiveInteger(
|
||||
process.env.FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS,
|
||||
5
|
||||
const maxAttempts = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS || "180", 10);
|
||||
const missingJobMaxAttempts = Number.parseInt(
|
||||
process.env.FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS || "12",
|
||||
10
|
||||
);
|
||||
const intervalSeconds = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS || "5", 10);
|
||||
const jobName = process.env.FORMBRICKS_MIGRATION_JOB_NAME;
|
||||
const migrationsDir = path.resolve("packages/database/dist/migration");
|
||||
const prisma = new PrismaClient();
|
||||
const namespace = fs
|
||||
.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8")
|
||||
.trim();
|
||||
@@ -115,125 +102,10 @@ spec:
|
||||
request.end();
|
||||
});
|
||||
|
||||
const loadExpectedMigrationMarkers = async () => {
|
||||
const entries = await fsp.readdir(migrationsDir, { withFileTypes: true });
|
||||
const schemaMigrationNames = [];
|
||||
const dataMigrationIds = [];
|
||||
|
||||
const migrationEntries = entries
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of migrationEntries) {
|
||||
const migrationPath = path.join(migrationsDir, entry.name);
|
||||
const files = await fsp.readdir(migrationPath);
|
||||
|
||||
if (files.includes("migration.sql")) {
|
||||
schemaMigrationNames.push(entry.name);
|
||||
}
|
||||
|
||||
if (files.includes("migration.js")) {
|
||||
const migrationModule = await import(
|
||||
pathToFileURL(path.join(migrationPath, "migration.js")).href
|
||||
);
|
||||
|
||||
for (const exportedValue of Object.values(migrationModule)) {
|
||||
if (
|
||||
exportedValue &&
|
||||
typeof exportedValue === "object" &&
|
||||
exportedValue.type === "data" &&
|
||||
exportedValue.id
|
||||
) {
|
||||
dataMigrationIds.push(exportedValue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { schemaMigrationNames, dataMigrationIds };
|
||||
};
|
||||
|
||||
let expectedMigrationMarkersPromise;
|
||||
const getExpectedMigrationMarkers = () => {
|
||||
if (!expectedMigrationMarkersPromise) {
|
||||
expectedMigrationMarkersPromise = loadExpectedMigrationMarkers();
|
||||
}
|
||||
|
||||
return expectedMigrationMarkersPromise;
|
||||
};
|
||||
|
||||
const hasFormbricksMigrationSuccessMarkers = async () => {
|
||||
try {
|
||||
const { schemaMigrationNames, dataMigrationIds } = await getExpectedMigrationMarkers();
|
||||
|
||||
// apply-migrations.js persists success in these DB tables after Prisma/data migrations complete.
|
||||
if (schemaMigrationNames.length === 0) {
|
||||
console.log(
|
||||
`No schema migrations found in ${migrationsDir}; refusing missing-Job success fallback.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const appliedSchemaMigrations = await prisma.$queryRaw`
|
||||
SELECT migration_name
|
||||
FROM _prisma_migrations
|
||||
WHERE finished_at IS NOT NULL
|
||||
AND rolled_back_at IS NULL
|
||||
AND migration_name IN (${Prisma.join(schemaMigrationNames)})
|
||||
`;
|
||||
const appliedSchemaMigrationNames = new Set(
|
||||
appliedSchemaMigrations.map((migration) => migration.migration_name)
|
||||
);
|
||||
const missingSchemaMigrations = schemaMigrationNames.filter(
|
||||
(migrationName) => !appliedSchemaMigrationNames.has(migrationName)
|
||||
);
|
||||
|
||||
if (missingSchemaMigrations.length > 0) {
|
||||
console.log(
|
||||
`Prisma migration markers are incomplete; ${missingSchemaMigrations.length} schema migration(s) are missing.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataMigrationIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const appliedDataMigrations = await prisma.$queryRaw`
|
||||
SELECT id
|
||||
FROM "DataMigration"
|
||||
WHERE status = 'applied'
|
||||
AND id IN (${Prisma.join(dataMigrationIds)})
|
||||
`;
|
||||
const appliedDataMigrationIds = new Set(
|
||||
appliedDataMigrations.map((migration) => migration.id)
|
||||
);
|
||||
const missingDataMigrations = dataMigrationIds.filter(
|
||||
(migrationId) => !appliedDataMigrationIds.has(migrationId)
|
||||
);
|
||||
|
||||
if (missingDataMigrations.length > 0) {
|
||||
console.log(
|
||||
`Data migration markers are incomplete; ${missingDataMigrations.length} data migration(s) are missing.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`Migration success markers are not ready: ${message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
let missingJobAttempts = 0;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const job = await fetchJob();
|
||||
missingJobAttempts = 0;
|
||||
const conditions = job.status?.conditions || [];
|
||||
const isComplete = conditions.some(
|
||||
(condition) => condition.type === "Complete" && condition.status === "True"
|
||||
@@ -256,30 +128,12 @@ spec:
|
||||
console.log(`Waiting for ${jobName} to complete (${attempt}/${maxAttempts})...`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (error && error.statusCode === 404) {
|
||||
missingJobAttempts += 1;
|
||||
|
||||
if (missingJobAttempts >= missingJobMaxAttempts) {
|
||||
const hasSuccessMarkers = await hasFormbricksMigrationSuccessMarkers();
|
||||
|
||||
if (hasSuccessMarkers) {
|
||||
console.log(
|
||||
`${jobName} was not found after ${missingJobAttempts} consecutive attempts, ` +
|
||||
"but all Formbricks migration success markers are present; starting Hub migrations."
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}; missing ${missingJobAttempts}/${missingJobMaxAttempts}): ${message}`
|
||||
);
|
||||
} else {
|
||||
missingJobAttempts = 0;
|
||||
console.log(
|
||||
`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`
|
||||
);
|
||||
if (error && error.statusCode === 404 && attempt >= missingJobMaxAttempts) {
|
||||
console.log(`${jobName} was not found after ${attempt} attempts; assuming it was already cleaned up.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`);
|
||||
}
|
||||
|
||||
await sleep(intervalSeconds * 1000);
|
||||
@@ -291,45 +145,8 @@ spec:
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect().catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
|
||||
envFrom:
|
||||
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
|
||||
- secretRef:
|
||||
name: {{ template "formbricks.name" . }}-app-secrets
|
||||
{{- end }}
|
||||
{{- range $value := .Values.deployment.envFrom }}
|
||||
{{- if (eq .type "configmap") }}
|
||||
- configMapRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if (eq .type "secret") }}
|
||||
- secretRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- range $key, $value := .Values.deployment.env }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
- name: FORMBRICKS_MIGRATION_JOB_NAME
|
||||
value: {{ include "formbricks.migrationJobName" . | quote }}
|
||||
- name: FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS
|
||||
|
||||
@@ -51,5 +51,5 @@ roleRef:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
namespace: {{ include "formbricks.namespace" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -46,7 +46,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migration
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
|
||||
@@ -670,10 +670,10 @@ hub:
|
||||
# Pinned by digest for immutable, reproducible deployments. When digest is set it takes
|
||||
# precedence over tag, and deployment, init container, and migration job all resolve to the
|
||||
# same immutable image. Update on each Hub release.
|
||||
# Current digest corresponds to ghcr.io/formbricks/hub:0.3.0.
|
||||
digest: "sha256:6c39b1143527137e881df785a5b668625a1fe3edb05485bb5ded19f813c8de88"
|
||||
# Current digest corresponds to ghcr.io/formbricks/hub:0.4.0.
|
||||
digest: "sha256:3971dd15fcce22d438ac916266ed72176052460a2bd91808e0995e894faba5bc"
|
||||
# Tag is a fallback for dev/non-prod when digest is cleared; keep aligned with the digest above.
|
||||
tag: "0.3.0"
|
||||
tag: "0.4.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional override for the secret Hub reads from.
|
||||
@@ -939,7 +939,6 @@ hub:
|
||||
waitForFormbricksMigration:
|
||||
enabled: true
|
||||
maxAttempts: 180
|
||||
# Consecutive missing Job reads before falling back to persisted Prisma/DataMigration markers.
|
||||
missingJobMaxAttempts: 12
|
||||
intervalSeconds: 5
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
# Keep hub, hub-migrate, and any future hub-worker on the same tag — they share one image and
|
||||
# drift breaks migrations or job processing.
|
||||
hub-migrate:
|
||||
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
|
||||
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.4.0}
|
||||
restart: "no"
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
@@ -112,7 +112,7 @@ services:
|
||||
|
||||
# Formbricks Hub API (ghcr.io/formbricks/hub). Uses a dedicated local Hub database by default.
|
||||
hub:
|
||||
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
|
||||
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.4.0}
|
||||
depends_on:
|
||||
hub-migrate:
|
||||
condition: service_completed_successfully
|
||||
@@ -142,7 +142,7 @@ services:
|
||||
|
||||
# Hub worker processes async jobs enqueued by the API, including embeddings.
|
||||
hub-worker:
|
||||
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
|
||||
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.4.0}
|
||||
depends_on:
|
||||
hub-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"generate": "turbo run generate",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test --no-cache",
|
||||
"test:coverage": "turbo run test:coverage --no-cache",
|
||||
"test:e2e": "playwright test",
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/ai.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
||||
Vendored
-1
@@ -30,7 +30,6 @@
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/cache.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
Vendored
+7
-10
@@ -91,17 +91,14 @@ describe("@formbricks/cache types/keys", () => {
|
||||
});
|
||||
|
||||
describe("CustomCacheNamespace type", () => {
|
||||
test("should support known custom namespaces in parsed cache keys", () => {
|
||||
test("should include expected namespaces", () => {
|
||||
// Type test - this will fail at compile time if types don't match
|
||||
const namespaces: CustomCacheNamespace[] = ["account_deletion", "analytics", "billing", "oauth"];
|
||||
const cacheKeys = namespaces.map((namespace) => ZCacheKey.parse(`${namespace}:test:123`));
|
||||
|
||||
expect(cacheKeys).toEqual([
|
||||
"account_deletion:test:123",
|
||||
"analytics:test:123",
|
||||
"billing:test:123",
|
||||
"oauth:test:123",
|
||||
]);
|
||||
const accountDeletionNamespace: CustomCacheNamespace = "account_deletion";
|
||||
const analyticsNamespace: CustomCacheNamespace = "analytics";
|
||||
const billingNamespace: CustomCacheNamespace = "billing";
|
||||
expect(accountDeletionNamespace).toBe("account_deletion");
|
||||
expect(analyticsNamespace).toBe("analytics");
|
||||
expect(billingNamespace).toBe("billing");
|
||||
});
|
||||
|
||||
test("should be usable in cache key construction", () => {
|
||||
|
||||
Vendored
+1
-1
@@ -16,4 +16,4 @@ export type CacheKey = z.infer<typeof ZCacheKey>;
|
||||
* Possible namespaces for custom cache keys
|
||||
* Add new namespaces here as they are introduced
|
||||
*/
|
||||
export type CustomCacheNamespace = "account_deletion" | "analytics" | "billing" | "oauth";
|
||||
export type CustomCacheNamespace = "account_deletion" | "analytics" | "billing";
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"generate": "prisma generate",
|
||||
"lint": "eslint ./src --fix",
|
||||
"generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"extends": "@formbricks/config-typescript/node16.json",
|
||||
"include": ["src/**/*.ts", "types/**/*.ts", "zod/**/*.ts", "migration/**/*.ts", "vite.config.ts"]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "tsup.config.ts"]
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3456",
|
||||
"build": "pnpm typecheck",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint src --fix --ext .ts,.tsx",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"go": "vite build --watch --mode dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"clean": "rimraf .turbo node_modules dist coverage",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -1,37 +1,2 @@
|
||||
import type { TWorkspaceStateSurvey } from "@/types/config";
|
||||
|
||||
export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
|
||||
|
||||
export const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
headline: { en: "Welcome" },
|
||||
},
|
||||
questions: [],
|
||||
variables: [],
|
||||
type: "app",
|
||||
showLanguageSwitch: false,
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
status: "inProgress",
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
displayOption: "displayMultiple",
|
||||
hiddenFields: { enabled: false },
|
||||
delay: 0,
|
||||
workspaceOverwrites: {},
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
languages: [],
|
||||
triggers: [],
|
||||
displayPercentage: 100,
|
||||
};
|
||||
|
||||
export const createMockSurvey = (id = mockSurveyId): TWorkspaceStateSurvey => ({
|
||||
...mockSurvey,
|
||||
id,
|
||||
});
|
||||
export const mockSurveyName = "Test Survey";
|
||||
|
||||
@@ -709,10 +709,7 @@ describe("time on page action handling", () => {
|
||||
clearTimeOnPageTimers();
|
||||
});
|
||||
|
||||
const createConfigWithTimeOnPageAction = (
|
||||
actionName: string,
|
||||
timeInSeconds: number
|
||||
): { get: Mock; update: Mock } => ({
|
||||
const createConfigWithTimeOnPageAction = (actionName: string, timeInSeconds: number) => ({
|
||||
get: vi.fn().mockReturnValue({
|
||||
workspace: {
|
||||
data: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import { createMockSurvey } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import type { TWorkspaceStateSurvey } from "@/types/config";
|
||||
|
||||
describe("SurveyStore", () => {
|
||||
let store: SurveyStore;
|
||||
@@ -26,7 +27,10 @@ describe("SurveyStore", () => {
|
||||
});
|
||||
|
||||
test("returns current survey when set", () => {
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(store.getSurvey()).toBe(mockSurvey);
|
||||
@@ -36,7 +40,10 @@ describe("SurveyStore", () => {
|
||||
describe("setSurvey", () => {
|
||||
test("updates survey and notifies listeners when survey changes", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
@@ -47,7 +54,10 @@ describe("SurveyStore", () => {
|
||||
|
||||
test("does not notify listeners when setting same survey", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
@@ -60,7 +70,10 @@ describe("SurveyStore", () => {
|
||||
describe("resetSurvey", () => {
|
||||
test("resets survey to null and notifies listeners", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
@@ -83,21 +96,27 @@ describe("SurveyStore", () => {
|
||||
describe("subscribe", () => {
|
||||
test("adds listener and returns unsubscribe function", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
store.setSurvey({ ...mockSurvey, id: "updated-survey-id" });
|
||||
store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TWorkspaceStateSurvey);
|
||||
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called after unsubscribe
|
||||
});
|
||||
|
||||
test("multiple listeners receive updates", () => {
|
||||
const listener1 = vi.fn();
|
||||
const listener2 = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.subscribe(listener1);
|
||||
store.subscribe(listener2);
|
||||
|
||||
@@ -69,20 +69,6 @@ describe("widget-file", () => {
|
||||
configure: vi.fn(),
|
||||
};
|
||||
|
||||
const createMockFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => ({
|
||||
renderSurvey: vi.fn(),
|
||||
setNonce: vi.fn(),
|
||||
});
|
||||
|
||||
const getFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => {
|
||||
const formbricksSurveys = window.formbricksSurveys;
|
||||
if (!formbricksSurveys) {
|
||||
throw new Error("window.formbricksSurveys is not set");
|
||||
}
|
||||
|
||||
return formbricksSurveys;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
@@ -153,7 +139,10 @@ describe("widget-file", () => {
|
||||
(filterSurveys as Mock).mockReturnValue([]);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -165,7 +154,7 @@ describe("widget-file", () => {
|
||||
|
||||
vi.advanceTimersByTime(mockSurvey.delay * 1000);
|
||||
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
survey: mockSurvey,
|
||||
appUrl: "https://fake.app",
|
||||
@@ -313,7 +302,10 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -327,7 +319,7 @@ describe("widget-file", () => {
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
@@ -370,7 +362,10 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -383,7 +378,7 @@ describe("widget-file", () => {
|
||||
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -427,7 +422,10 @@ describe("widget-file", () => {
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -439,7 +437,7 @@ describe("widget-file", () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// The contactId passed to renderSurvey should be read after the wait
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_after_identification",
|
||||
})
|
||||
@@ -454,7 +452,10 @@ describe("widget-file", () => {
|
||||
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
@@ -466,7 +467,7 @@ describe("widget-file", () => {
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"User identification failed. Skipping survey with segment filters."
|
||||
);
|
||||
expect(getFormbricksSurveys().renderSurvey).not.toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
|
||||
@@ -597,7 +598,8 @@ describe("widget-file", () => {
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Set the global after script "loads" — simulates browser finishing execution
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
// Advance one polling interval for waitForSurveysGlobal to find it
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
@@ -607,8 +609,8 @@ describe("widget-file", () => {
|
||||
// Run remaining timers for survey.delay setTimeout
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(getFormbricksSurveys().setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appUrl: "https://fake.app",
|
||||
workspaceId: "env_123",
|
||||
@@ -627,11 +629,13 @@ describe("widget-file", () => {
|
||||
// After the previous successful test, surveysLoadPromise holds a resolved promise.
|
||||
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
|
||||
// should reuse the cached promise rather than creating a new script element.
|
||||
// @ts-expect-error -- cleaning up mock to force dedup path
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -649,7 +653,7 @@ describe("widget-file", () => {
|
||||
});
|
||||
expect(scriptAppendCalls.length).toBe(0);
|
||||
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -709,7 +713,10 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -724,7 +731,7 @@ describe("widget-file", () => {
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -239,6 +239,7 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
const storedNonce = globalThis.window.__formbricksNonce;
|
||||
if (storedNonce) {
|
||||
@@ -261,6 +262,7 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
return Promise.resolve(globalThis.window.formbricksSurveys);
|
||||
}
|
||||
@@ -298,6 +300,7 @@ let isPreloaded = false;
|
||||
|
||||
export const preloadSurveysScript = (appUrl: string): void => {
|
||||
// Don't preload if already loaded or already preloading
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) return;
|
||||
if (isPreloaded) return;
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build && tsc --project tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"go": "vite build --watch --mode dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist coverage",
|
||||
|
||||
@@ -5,10 +5,10 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
||||
export type ButtonSize = "default" | "custom" | "sm" | "lg" | "icon";
|
||||
interface ButtonVariantProps {
|
||||
type ButtonVariantProps = {
|
||||
variant?: ButtonVariant | null;
|
||||
size?: ButtonSize | null;
|
||||
}
|
||||
};
|
||||
type ButtonVariantClassProps =
|
||||
| (ButtonVariantProps & { class?: string; className?: never })
|
||||
| (ButtonVariantProps & { class?: never; className?: string })
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"build:analyze": "cross-env NODE_OPTIONS=--max-old-space-size=8192 ANALYZE=true vite build && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
|
||||
"build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --mode dev && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
|
||||
"go": "concurrently -n \"ESM,UMD\" \"vite build --watch --mode dev\" \"BUILD_UMD=true vite build --watch --mode dev\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("Survey Logic", () => {
|
||||
|
||||
const mockSurvey: TJsWorkspaceStateSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [], // Deprecated - using blocks instead
|
||||
blocks: [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
-32
@@ -15,9 +15,6 @@
|
||||
"@formbricks/ai#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/ai#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/cache#build": {
|
||||
"dependsOn": ["@formbricks/logger#build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -34,9 +31,6 @@
|
||||
"@formbricks/cache#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/cache#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/database#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", "../../node_modules/.prisma/client/**"]
|
||||
@@ -44,9 +38,6 @@
|
||||
"@formbricks/database#lint": {
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/database#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#generate"]
|
||||
},
|
||||
"@formbricks/email#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
@@ -86,9 +77,6 @@
|
||||
"@formbricks/js-core#lint": {
|
||||
"dependsOn": ["@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/js-core#typecheck": {
|
||||
"dependsOn": ["@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/logger#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -111,9 +99,6 @@
|
||||
"@formbricks/storage#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/storage#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/survey-ui#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -146,9 +131,6 @@
|
||||
"@formbricks/surveys#test:coverage": {
|
||||
"dependsOn": ["@formbricks/survey-ui#build"]
|
||||
},
|
||||
"@formbricks/surveys#typecheck": {
|
||||
"dependsOn": ["@formbricks/i18n-utils#build", "@formbricks/survey-ui#build"]
|
||||
},
|
||||
"@formbricks/web#dev": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
@@ -196,16 +178,6 @@
|
||||
"@formbricks/surveys#build"
|
||||
]
|
||||
},
|
||||
"@formbricks/web#typecheck": {
|
||||
"dependsOn": [
|
||||
"@formbricks/ai#build",
|
||||
"@formbricks/cache#build",
|
||||
"@formbricks/database#build",
|
||||
"@formbricks/logger#build",
|
||||
"@formbricks/storage#build",
|
||||
"@formbricks/surveys#build"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"env": [
|
||||
@@ -422,10 +394,6 @@
|
||||
},
|
||||
"test:coverage": {
|
||||
"outputs": []
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["@formbricks/database#generate", "^typecheck"],
|
||||
"outputs": []
|
||||
}
|
||||
},
|
||||
"ui": "stream"
|
||||
|
||||
Reference in New Issue
Block a user