Compare commits

..

64 Commits

Author SHA1 Message Date
Dhruwang Jariwala 880385029d fix: [Backport] restore logout and profile access for billing-role users (#8136) (#8137)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:03:16 +02:00
Dhruwang Jariwala 062a59efed fix: harden Helm release secret lookups (#8119) 2026-05-22 17:27:29 +05:30
Bhagya Amarasinghe b577c151c8 fix: harden Helm release secret lookups 2026-05-22 17:09:07 +05:30
Dhruwang Jariwala f39f12ec1c feat: [Backport] cascade delete Hub feedback records on org deletion (#8055) (#8116) 2026-05-22 16:22:43 +05:30
Dhruwang 01fb38cff8 chore: bump Hub image to 0.4.0 for tenant-scoped delete endpoint
Cascade delete in deleteOrganization calls DELETE /v1/tenants/{tenant_id}/data,
which is first available in Hub 0.4.0. Bumps the dev-compose default tag and the
Helm chart digest/tag so deployments don't silently land on the older Hub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:50 +05:30
Dhruwang 71e57947b9 fix: switch Hub purge to DELETE /v1/tenants/{tenant_id}/data
Hub#77 ships the proper tenant-data purge endpoint that nukes feedback
records, derived embeddings, and webhooks in one idempotent call. Swap
the placeholder bulkDelete cast for a direct client.delete<>() against
the new path and surface the per-resource counts.

Renames deleteFeedbackRecordsByTenant → deleteHubTenantData to reflect
that it deletes more than feedback records. Org-deletion call site and
tests updated accordingly. Best-effort error handling unchanged.
2026-05-22 16:08:49 +05:30
Dhruwang 5f6d6d53b2 feat: cascade delete Hub feedback records on org deletion (ENG-973)
Add a tenant-scoped purge wrapper in the Hub gateway and call it for
each FeedbackDirectory after an organization is deleted, so Hub records
do not become orphaned when the local cascade clears the directory rows.

Depends on a Hub-side change to accept a tenant-only bulkDelete payload;
the call is best-effort and failures are logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:49 +05:30
Anshuman Pandey b79758ee49 fix: [Backport] json payload limit (#8115)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 12:55:14 +04:00
Bhagya Amarasinghe 25b0b89e86 fix: use Valkey for bundled Helm Redis (backport #8092) (#8105) 2026-05-22 11:39:11 +05:30
Bhagya Amarasinghe f228ce7eb6 fix: address Helm Valkey review comments 2026-05-22 11:36:36 +05:30
Bhagya Amarasinghe 67ae13a61a fix: use Valkey for bundled Helm Redis 2026-05-22 11:36:31 +05:30
Johannes 70e72ab0de fix: backport removal of isAIDataAnalysisEnabled to v5 (#8112) 2026-05-21 20:38:04 +02:00
Johannes 07d1d918ba fix: backport CSAT and CES summary filter icons to 5.0 (#8058)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 17:56:52 +02:00
Bhagya Amarasinghe a98a0a8e73 fix: pin DNS and block redirects on webhook delivery (backport #8095) (#8106) 2026-05-21 19:10:59 +05:30
Bhagya Amarasinghe a5a67a05de fix: order Helm Hub migrations after Prisma (backport #8104) (#8107) 2026-05-21 19:10:32 +05:30
Bhagya Amarasinghe 4876e107f8 fix: order Helm Hub migrations after Prisma 2026-05-21 18:33:44 +05:30
Bhagya Amarasinghe 5db616ac07 fix(webhooks): ignore dispatcher cleanup failures 2026-05-21 18:33:25 +05:30
pandeymangg fa1ccdb2c3 pin DNS and block redirects on webhook delivery 2026-05-21 18:33:25 +05:30
Dhruwang Jariwala 6183ab4744 fix: backport #8101 reserved contact keys and segment errors to 5.0 (#8103) 2026-05-21 17:12:03 +05:30
Dhruwang Jariwala 431a3d8a76 fix: allow enterprise oauth display names (#8100) 2026-05-21 16:30:16 +05:30
Dhruwang Jariwala 91ab958379 fix: chart date range type switch + presets include today (backport #8096) (#8097) 2026-05-21 16:26:59 +05:30
Dhruwang Jariwala e5df832653 fix: [Backport] adds close button on response error screen (#8098) 2026-05-21 16:26:09 +05:30
Johannes 0909c38eb1 code rabbit comments
(cherry picked from commit 8fb2287ae7)
2026-05-21 12:46:03 +02:00
Johannes 08b0d95295 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994)
Block creation of reserved safe-identifier contact keys across API, SDK, and CSV flows to prevent collisions ahead of the v5.1 migration, and surface clearer personal-link/segment validation errors instead of unknown failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 9da9f1582c)
2026-05-21 12:46:03 +02:00
pandeymangg a49e989413 refactor: replace custom functions with date-fns 2026-05-21 15:57:30 +05:30
Cursor Agent 9445b2f482 fix: restrict user name whitespace 2026-05-21 10:22:24 +00:00
Cursor Agent f07d832516 test: anonymize oauth display name fixture 2026-05-21 10:22:24 +00:00
Cursor Agent e615c692a9 fix: allow enterprise oauth display names
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:22:24 +00:00
Dhruwang Jariwala a9a910d15c fix: AI translation rich-text editors stay empty (backport #8084) (#8091) 2026-05-21 12:09:33 +02:00
pandeymangg c425e7aff4 adds close button on response error screen 2026-05-21 15:18:35 +05:30
Dhruwang a83a54a24a fix: chart date range type switch + presets include today (ENG-1034, ENG-1035)
ENG-1034: in the chart editor, switching the date range type from Custom
back to Preset would leave Update chart disabled because the toggle only
updated local UI state and never propagated the change to the parent
config. hasConfigChanged compared two queries that still held the old
custom [Date, Date] range, so it stayed false. The new
handleDateRangeTypeChange seats the parent timeDimension.dateRange to
match the chosen type (preset string or [start, end] tuple) on toggle
so hasConfigChanged can re-evaluate.

ENG-1035: Cube v1.6.6's native "last 7 days" / "last 30 days" / "this
month" presets anchor to end-of-yesterday and exclude today, which
diverges from every other analytics tool (GA, Mixpanel, PostHog, ...).
Added expandPresetDateRanges that rewrites known preset strings to
explicit inclusive [YYYY-MM-DD, YYYY-MM-DD] tuples at the Cube boundary
only. Stored client queries keep the preset string so hasConfigChanged
stays consistent and saved charts round-trip back to the Preset UI.
Unknown preset strings pass through unchanged.
2026-05-21 14:54:57 +05:30
Dhruwang Jariwala f7890eaec3 fix: backport billing-only settings access to 5.0 (#8090) 2026-05-21 13:02:25 +05:30
Dhruwang Jariwala 8cd3187eff fix: backport settings back navigation to 5.0 (#8089) 2026-05-21 13:02:09 +05:30
Dhruwang Jariwala 83bccc7ded fix: backport Cube API secret Helm defaults to 5.0 (#8088) 2026-05-21 12:50:13 +05:30
Dhruwang Jariwala 00aa6d5247 fix: [Backport] backports removal of timestamps from client responses api (#8087) 2026-05-21 12:45:12 +05:30
Dhruwang Jariwala 0657c94ee5 fix: [Backport] excel injection backport (#8086) 2026-05-21 12:44:46 +05:30
Johannes a36cef2936 fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit c0bf2ab7cc)
2026-05-21 07:11:50 +00:00
Johannes 467af8b6ef fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit 13c9677edd)
2026-05-21 07:09:46 +00:00
Dhruwang Jariwala d0e057eac1 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (backport #8061) (#8085) 2026-05-21 12:30:59 +05:30
Bhagya Amarasinghe ef1f5a2b12 fix: wire Cube API secret into Helm defaults
(cherry picked from commit 0e65278af7)
2026-05-21 07:00:06 +00:00
pandeymangg 770041923f backports removal of timestamps from client responses api 2026-05-21 12:14:11 +05:30
pandeymangg 3e66ff25a1 backports excel injection fix 2026-05-21 12:08:05 +05:30
Dhruwang Jariwala c979909da9 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:00:28 +05:30
Dhruwang Jariwala 010d96ebcd fix: harden Helm env value rendering (backport #8070) (#8078) 2026-05-21 11:49:30 +05:30
Dhruwang Jariwala a0b3054f4a fix: [Backport] responseId client api fix (#8083) 2026-05-21 11:47:34 +05:30
Dhruwang Jariwala 02d3cd2af3 fix: update Helm chart default image tag (backport #8072) (#8081) 2026-05-21 11:17:40 +05:30
Dhruwang Jariwala 2ef4eb4345 fix: require Cube API secret in compose (backport #8071) (#8080) 2026-05-21 11:17:25 +05:30
Dhruwang Jariwala 093757b386 fix: render scheduled-plan-change description placeholders correctly (backport #8064) (#8077) 2026-05-21 11:17:10 +05:30
pandeymangg 64f8746940 responseId api fix 2026-05-21 11:13:08 +05:30
Dhruwang Jariwala 851616078a fix: gate AI chart generation on smartTools, not dataAnalysis (backport #8060) (#8076) 2026-05-21 11:01:47 +05:30
Dhruwang Jariwala 06d5313629 fix: route Manage Teams and integration OAuth callbacks to settings (backport #8059) (#8075) 2026-05-21 11:01:34 +05:30
Bhagya Amarasinghe 7834c21d39 fix: update Helm chart default image tag (#8072) 2026-05-21 10:58:09 +05:30
Bhagya Amarasinghe f98ca39035 fix: require Cube API secret in compose (#8071) 2026-05-21 10:57:57 +05:30
Bhagya Amarasinghe 48f928b1bf fix: harden Helm env value rendering (#8070) 2026-05-21 10:48:28 +05:30
Dhruwang Jariwala f5dfb4739c fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:53 +05:30
Anshuman Pandey 5a1fc01388 fix: [Backport] client environment api sdk fixes (#8074) 2026-05-21 09:16:53 +04:00
Dhruwang Jariwala 77a39c13fa fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:17 +05:30
Dhruwang Jariwala 5a12539c75 fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:45:43 +05:30
Dhruwang Jariwala 74e0fba757 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0] (#8069) 2026-05-21 10:25:56 +05:30
Dhruwang Jariwala d9c2756185 fix: sync chart Cube feedback schema (backport #8057 to release/5.0) (#8066) 2026-05-21 10:20:53 +05:30
Matti Nannt 88ad5c8625 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0]
doesContactExist checked contact existence by id only, allowing a caller
to bind a display in one workspace to a contact from a different workspace.
Add workspaceId filter and rename to doesContactExistInWorkspace.

Backport of #8048 to release/5.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:27:48 +02:00
Bhagya Amarasinghe 610beee7eb fix: sync chart Cube feedback schema
Backport of #8057 to release/5.0 (excludes test file changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:55:59 +05:30
Javi Aguilar db0e2bb105 fix: add CSAT and CES summary filter icons (backport #8056) (#8063) 2026-05-20 15:05:47 +02:00
Johannes 419ceef413 fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 14:38:23 +02:00
85 changed files with 673 additions and 2259 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
version: v3.15.4
- name: Log in to GitHub Container Registry
env:
-1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App.tsx";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -571,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);
@@ -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;
}
+38 -92
View File
@@ -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 -17
View File
@@ -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 });
+2 -11
View File
@@ -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 () => {
+5 -4
View File
@@ -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");
@@ -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",
-76
View File
@@ -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 -2
View File
@@ -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")) {
+1 -53
View File
@@ -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();
});
});
-215
View File
@@ -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,
};
};
+26
View File
@@ -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");
});
});
});
+13
View File
@@ -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);
-31
View File
@@ -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);
});
});
});
-8
View File
@@ -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());
});
});
});
+51
View File
@@ -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}
-2
View File
@@ -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",
-8
View File
@@ -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;
}
}
-26
View File
@@ -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"
]
}
+2 -3
View File
@@ -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 -1
View File
@@ -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:
---
+36 -34
View File
@@ -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 -}}
+1 -1
View File
@@ -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:
+11 -194
View File
@@ -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
+3 -4
View File
@@ -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
+3 -3
View File
@@ -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
-1
View File
@@ -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",
-1
View File
@@ -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"
-1
View File
@@ -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",
+7 -10
View File
@@ -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", () => {
+1 -1
View File
@@ -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";
-1
View File
@@ -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": {
+1 -1
View File
@@ -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"]
}
+1 -2
View File
@@ -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"
},
-1
View File
@@ -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",
-1
View File
@@ -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;
-1
View File
@@ -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"
},
-1
View File
@@ -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",
-1
View File
@@ -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 })
-1
View File
@@ -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",
+1
View File
@@ -27,6 +27,7 @@ describe("Survey Logic", () => {
const mockSurvey: TJsWorkspaceStateSurvey = {
id: "survey1",
name: "Test Survey",
questions: [], // Deprecated - using blocks instead
blocks: [
{
-1
View File
@@ -11,7 +11,6 @@
"sideEffects": false,
"scripts": {
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"typecheck": "tsc --noEmit",
"clean": "rimraf node_modules .turbo"
},
"dependencies": {
-1
View File
@@ -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
View File
@@ -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"