mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-24 02:47:53 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf2ef36ceb | |||
| aa040595c3 | |||
| 81c2bd365a | |||
| b26945698d | |||
| 208d83eb08 | |||
| 0a7482da0f |
@@ -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 */}
|
||||
|
||||
+12
-8
@@ -76,10 +76,12 @@ HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disabl
|
||||
# EMBEDDING_MODEL=gemini-embedding-001
|
||||
# EMBEDDING_PROVIDER_API_KEY=
|
||||
|
||||
####################
|
||||
# CUBE ANALYTICS #
|
||||
####################
|
||||
# Cube semantic-layer API. Required. The bundled Docker stack exposes Cube on port 4000.
|
||||
###########################
|
||||
# CUBE ANALYTICS (XM V5) #
|
||||
###########################
|
||||
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
|
||||
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
|
||||
# COMPOSE_PROFILES=xm
|
||||
CUBEJS_API_URL=http://localhost:4000
|
||||
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
|
||||
CUBEJS_API_SECRET=
|
||||
@@ -155,12 +157,11 @@ PASSWORD_RESET_DISABLED=1
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion SSO confirmation #
|
||||
# Account deletion reauthentication #
|
||||
###########################################
|
||||
|
||||
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
|
||||
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
|
||||
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
|
||||
|
||||
|
||||
##########
|
||||
@@ -188,6 +189,9 @@ GITHUB_SECRET=
|
||||
# Configure Google Login
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
|
||||
# Keep this unset until that setting is active for the OAuth app.
|
||||
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
|
||||
|
||||
# Configure Azure Active Directory Login
|
||||
AZUREAD_CLIENT_ID=
|
||||
|
||||
@@ -31,14 +31,14 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Get the latest release tag from GitHub API with error handling
|
||||
echo "Fetching latest release from GitHub API..."
|
||||
|
||||
|
||||
# Use curl with error handling - API returns 404 if no releases exist
|
||||
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
|
||||
|
||||
|
||||
if [[ "$http_code" == "404" ]]; then
|
||||
echo "⚠️ No previous releases found (404). This appears to be the first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
echo "Current release tag: ${{ github.event.release.tag_name }}"
|
||||
|
||||
- name: Compare release tags
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Handle first release case (no previous releases)
|
||||
if [[ -z "${LATEST_TAG}" ]]; then
|
||||
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
|
||||
@@ -156,87 +156,6 @@ jobs:
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
update-helm-app-version:
|
||||
name: Create Helm app version update
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- helm-chart-release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install YQ
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Prepare Helm app version update
|
||||
id: update
|
||||
env:
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
|
||||
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
|
||||
|
||||
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
|
||||
echo "Helm chart appVersion already matches ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Helm app version PR
|
||||
if: steps.update.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="chore/update-helm-app-version-${VERSION}"
|
||||
title="chore: update Helm app version to ${VERSION}"
|
||||
body_file="$(mktemp)"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$branch"
|
||||
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
cat > "$body_file" <<EOF
|
||||
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
|
||||
|
||||
Release candidates and pre-releases do not create this source update.
|
||||
EOF
|
||||
|
||||
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
|
||||
else
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
|
||||
fi
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
@@ -246,7 +165,6 @@ jobs:
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
- update-helm-app-version
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||
with:
|
||||
version: v3.15.4
|
||||
version: latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
env:
|
||||
@@ -70,25 +70,6 @@ jobs:
|
||||
|
||||
echo "✅ Successfully updated Chart.yaml"
|
||||
|
||||
- name: Validate default Formbricks image tag
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
rendered="$(helm template qa charts/formbricks \
|
||||
--set formbricks.webappUrl=https://qa.example.com \
|
||||
--show-only templates/deployment.yaml \
|
||||
--show-only templates/migration-job.yaml)"
|
||||
|
||||
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
|
||||
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
|
||||
if [[ "$image_count" -ne 2 ]]; then
|
||||
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
|
||||
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package Helm chart
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ const Page = async (props: ModePageProps) => {
|
||||
}
|
||||
|
||||
const experimentVariant =
|
||||
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
|
||||
(await getPostHogFeatureFlag(session.user.id, "onboarding-mode-experiment")) || "control";
|
||||
|
||||
if (experimentVariant === "remove-cx-and-surveys-mode") {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
|
||||
|
||||
@@ -194,7 +194,7 @@ export const MainNavigation = ({
|
||||
const settingsNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.settings"),
|
||||
href: `/workspaces/${workspace.id}/settings/workspace/general`,
|
||||
href: `/workspaces/${workspace.id}/settings`,
|
||||
icon: SettingsIcon,
|
||||
isActive: isSettingsMode,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
@@ -467,7 +467,7 @@ export const MainNavigation = ({
|
||||
{isSettingsMode ? (
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="mb-2 px-3">
|
||||
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
|
||||
<GoBackButton />
|
||||
</div>
|
||||
|
||||
{/* Settings sidebar content */}
|
||||
|
||||
@@ -335,7 +335,6 @@ export const SettingsSidebarContent = ({
|
||||
href: `${basePath}/organization/feedback-directories`,
|
||||
icon: <FoldersIcon className={iconClassName} />,
|
||||
hidden: isMember,
|
||||
disabled: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "org-api-keys",
|
||||
@@ -374,14 +373,12 @@ export const SettingsSidebarContent = ({
|
||||
label: t("common.your_profile"),
|
||||
href: `${basePath}/account/profile`,
|
||||
icon: <UserCircleIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: t("common.notifications"),
|
||||
href: `${basePath}/account/notifications`,
|
||||
icon: <BellIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
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);
|
||||
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
|
||||
+11
-11
@@ -8,8 +8,8 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import {
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
} from "@/modules/account/constants";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
@@ -22,7 +22,6 @@ interface DeleteAccountProps {
|
||||
accountDeletionError?: string | string[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
isSsoIdentityConfirmationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccount = ({
|
||||
@@ -33,7 +32,6 @@ export const DeleteAccount = ({
|
||||
accountDeletionError,
|
||||
isMultiOrgEnabled,
|
||||
requiresPasswordConfirmation,
|
||||
isSsoIdentityConfirmationDisabled,
|
||||
}: Readonly<DeleteAccountProps>) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||
@@ -44,18 +42,21 @@ export const DeleteAccount = ({
|
||||
const hasShownAccountDeletionError = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
|
||||
hasShownAccountDeletionError.current
|
||||
) {
|
||||
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownAccountDeletionError.current = true;
|
||||
|
||||
toast.error(t("workspace.settings.profile.sso_identity_confirmation_failed"), {
|
||||
id: "account-deletion-sso-confirmation-error",
|
||||
});
|
||||
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
toast.error(t("workspace.settings.profile.google_sso_account_deletion_requires_setup"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("workspace.settings.profile.sso_reauthentication_failed"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
||||
@@ -75,7 +76,6 @@ export const DeleteAccount = ({
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isSsoIdentityConfirmationDisabled={isSsoIdentityConfirmationDisabled}
|
||||
/>
|
||||
<p className="text-sm text-slate-700">
|
||||
<strong>{t("workspace.settings.profile.warning_cannot_undo")}</strong>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/acc
|
||||
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/EditProfileDetailsForm";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import {
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
@@ -99,7 +98,6 @@ const Page = async (props: {
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
isSsoIdentityConfirmationDisabled={DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getBillingFallbackPath: vi.fn(),
|
||||
getWorkspaceAuth: vi.fn(),
|
||||
isFormbricksCloud: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/navigation", () => ({
|
||||
getBillingFallbackPath: mocks.getBillingFallbackPath,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/workspaces/lib/utils", () => ({
|
||||
getWorkspaceAuth: mocks.getWorkspaceAuth,
|
||||
}));
|
||||
|
||||
const workspaceId = "workspace-1";
|
||||
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
|
||||
|
||||
const getWorkspaceAuthResponse = (isBilling: boolean) =>
|
||||
({
|
||||
isBilling,
|
||||
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
|
||||
|
||||
describe("redirectBillingRoleFromRestrictedSettings", () => {
|
||||
test("does not redirect non-billing workspace members", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
|
||||
|
||||
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).not.toHaveBeenCalled();
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects billing users to the billing fallback path", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
|
||||
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
|
||||
|
||||
await redirectBillingRoleFromRestrictedSettings(workspaceId);
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
|
||||
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
|
||||
const { isBilling } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
if (isBilling) {
|
||||
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return APIKeysPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default APIKeysPage;
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { PricingPage } from "@/modules/ee/billing/page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && !IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
return PricingPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default PricingPage;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -13,9 +12,8 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
|
||||
+5
@@ -66,6 +66,11 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "aiDataAnalysis",
|
||||
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "auditLogs",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
|
||||
|
||||
+4
-9
@@ -1,10 +1,9 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -12,19 +11,15 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
if (isPricingDisabled) {
|
||||
|
||||
+1
-11
@@ -1,11 +1 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return FeedbackDirectoriesPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
|
||||
|
||||
+6
@@ -57,6 +57,7 @@ describe("organization AI settings actions", () => {
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
||||
@@ -65,6 +66,7 @@ describe("organization AI settings actions", () => {
|
||||
mocks.updateOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
||||
});
|
||||
@@ -112,15 +114,18 @@ describe("organization AI settings actions", () => {
|
||||
oldObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
newObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,6 +194,7 @@ describe("organization AI settings actions", () => {
|
||||
mocks.getOrganization.mockResolvedValueOnce({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||
|
||||
|
||||
+9
-2
@@ -71,11 +71,12 @@ export const updateOrganizationNameAction = authenticatedActionClient
|
||||
|
||||
type TOrganizationAISettings = Pick<
|
||||
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
|
||||
"isAISmartToolsEnabled"
|
||||
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
|
||||
>;
|
||||
|
||||
type TResolvedOrganizationAISettings = {
|
||||
smartToolsEnabled: boolean;
|
||||
dataAnalysisEnabled: boolean;
|
||||
isEnablingAnyAISetting: boolean;
|
||||
};
|
||||
|
||||
@@ -89,10 +90,16 @@ const resolveOrganizationAISettings = ({
|
||||
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
|
||||
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
|
||||
: organization.isAISmartToolsEnabled;
|
||||
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
|
||||
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
|
||||
: organization.isAIDataAnalysisEnabled;
|
||||
|
||||
return {
|
||||
smartToolsEnabled,
|
||||
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
|
||||
dataAnalysisEnabled,
|
||||
isEnablingAnyAISetting:
|
||||
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
|
||||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+25
-4
@@ -50,18 +50,29 @@ export const AISettingsToggle = ({
|
||||
currentValue: organization.isAISmartToolsEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
||||
currentValue: organization.isAIDataAnalysisEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
const handleToggle = async (
|
||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
||||
checked: boolean
|
||||
) => {
|
||||
if (checked && !aiEnablementState.canEnableFeatures) {
|
||||
toast.error(aiEnablementBlockedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingField("isAISmartToolsEnabled");
|
||||
setLoadingField(field);
|
||||
try {
|
||||
const data =
|
||||
field === "isAISmartToolsEnabled"
|
||||
? { isAISmartToolsEnabled: checked }
|
||||
: { isAIDataAnalysisEnabled: checked };
|
||||
const response = await updateOrganizationAISettingsAction({
|
||||
organizationId: organization.id,
|
||||
data: { isAISmartToolsEnabled: checked },
|
||||
data,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
@@ -111,7 +122,7 @@ export const AISettingsToggle = ({
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={displayedSmartToolsValue}
|
||||
onToggle={handleToggle}
|
||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
||||
htmlId="ai-smart-tools-toggle"
|
||||
title={t("workspace.settings.general.ai_smart_tools_enabled")}
|
||||
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
|
||||
@@ -119,6 +130,16 @@ export const AISettingsToggle = ({
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={displayedDataAnalysisValue}
|
||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
||||
htmlId="ai-data-analysis-toggle"
|
||||
title={t("workspace.settings.general.ai_data_analysis_enabled")}
|
||||
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
|
||||
disabled={isToggleDisabled}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
{!canEdit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import {
|
||||
getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
getWhiteLabelPermission,
|
||||
@@ -26,9 +26,8 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
|
||||
@@ -37,11 +36,14 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
]);
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
|
||||
await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
getIsAIDataAnalysisEnabled(organization.id),
|
||||
]);
|
||||
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
|
||||
|
||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||
const currentUserRole = currentUserMembership?.role;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
|
||||
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
});
|
||||
|
||||
export const ZUpdateOrganizationAISettingsAction = z.object({
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { TeamsPage } from "@/modules/organization/settings/teams/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return TeamsPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default TeamsPage;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
);
|
||||
|
||||
if (!contactsResult || contactsResult.length === 0) {
|
||||
throw new InvalidInputError("No contacts found for the selected segment");
|
||||
throw new UnknownError("No contacts found for the selected segment");
|
||||
}
|
||||
|
||||
capturePostHogEvent(
|
||||
|
||||
+39
-8
@@ -5,29 +5,35 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
import { ResponseSampleModal } from "./ResponseSampleModal";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
elementSummary: TSurveyElementSummaryOpenText;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSummaryProps) => {
|
||||
export const OpenTextSummary = ({
|
||||
elementSummary,
|
||||
survey,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: Readonly<OpenTextSummaryProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
||||
);
|
||||
@@ -48,17 +54,31 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
|
||||
<TableRow>
|
||||
<TableHead className="w-1/4">{t("common.user")}</TableHead>
|
||||
<TableHead className="w-2/4">{t("common.response")}</TableHead>
|
||||
<TableHead className="w-1/4">{t("common.time")}</TableHead>
|
||||
<TableHead className="w-1/6">{t("common.time")}</TableHead>
|
||||
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableRow
|
||||
key={response.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("workspace.surveys.summary.open_response_details")}
|
||||
className="cursor-pointer hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
onClick={() => setSelectedResponseId(response.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedResponseId(response.id);
|
||||
}
|
||||
}}>
|
||||
<TableCell className="w-1/4">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/workspaces/${workspace?.id}/contacts/${response.contact.id}`}>
|
||||
href={`/workspaces/${survey.workspaceId}/contacts/${response.contact.id}`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
@@ -80,9 +100,12 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/4">
|
||||
<TableCell className="w-1/6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
|
||||
<IdBadge id={response.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -96,6 +119,14 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponseSampleModal
|
||||
responseId={selectedResponseId}
|
||||
onClose={() => setSelectedResponseId(null)}
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import {
|
||||
getResponseAction,
|
||||
getTagsByWorkspaceIdAction,
|
||||
} from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ResponseSampleModalProps {
|
||||
responseId: string | null;
|
||||
onClose: () => void;
|
||||
survey: TSurvey;
|
||||
isReadOnly: boolean;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ResponseSampleModal = ({
|
||||
responseId,
|
||||
onClose,
|
||||
survey,
|
||||
isReadOnly,
|
||||
locale,
|
||||
}: Readonly<ResponseSampleModalProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
|
||||
const [tags, setTags] = useState<TTag[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Cache fetched data per response ID to avoid re-fetching on re-open
|
||||
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
|
||||
// Track the in-flight request so stale resolutions can be ignored when the user
|
||||
// switches rows quickly.
|
||||
const latestRequestId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!responseId) return;
|
||||
|
||||
const cached = cache.current.get(responseId);
|
||||
if (cached) {
|
||||
setResponse(cached.response);
|
||||
setTags(cached.tags);
|
||||
setErrorMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
latestRequestId.current = responseId;
|
||||
setIsLoading(true);
|
||||
setResponse(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
Promise.all([
|
||||
getResponseAction({ responseId }),
|
||||
getTagsByWorkspaceIdAction({ workspaceId: survey.workspaceId }),
|
||||
])
|
||||
.then(([responseResult, tagsResult]) => {
|
||||
// Discard if a newer request has started or the modal has been closed.
|
||||
if (latestRequestId.current !== responseId) return;
|
||||
|
||||
const responseError = getFormattedErrorMessage(responseResult);
|
||||
const tagsError = getFormattedErrorMessage(tagsResult);
|
||||
const fetchedResponse = responseResult?.data ?? null;
|
||||
const fetchedTags = tagsResult?.data ?? [];
|
||||
|
||||
if (responseError || tagsError || !fetchedResponse) {
|
||||
const message = responseError || tagsError || t("common.something_went_wrong");
|
||||
toast.error(message);
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { response: fetchedResponse, tags: fetchedTags };
|
||||
cache.current.set(responseId, entry);
|
||||
setResponse(entry.response);
|
||||
setTags(entry.tags);
|
||||
})
|
||||
.catch(() => {
|
||||
if (latestRequestId.current !== responseId) return;
|
||||
const message = t("common.something_went_wrong");
|
||||
toast.error(message);
|
||||
setErrorMessage(message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (latestRequestId.current !== responseId) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [responseId, survey.workspaceId, t]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
// Drop any in-flight request so it can't commit after close.
|
||||
latestRequestId.current = null;
|
||||
setErrorMessage(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
|
||||
<DialogContent width="wide">
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>{t("common.response")}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogDescription>{t("common.response")}</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<div className="py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="py-12 text-center text-sm text-slate-600">{errorMessage}</div>
|
||||
) : response ? (
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={response}
|
||||
environmentTags={tags}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+9
-1
@@ -41,9 +41,16 @@ interface SummaryListProps {
|
||||
responseCount: number | null;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryListProps) => {
|
||||
export const SummaryList = ({
|
||||
summary,
|
||||
responseCount,
|
||||
survey,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: Readonly<SummaryListProps>) => {
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
@@ -116,6 +123,7 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -49,6 +49,7 @@ interface SummaryPageProps {
|
||||
locale: TUserLocale;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
isQuotasAllowed: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -57,7 +58,8 @@ export const SummaryPage = ({
|
||||
locale,
|
||||
initialSurveySummary,
|
||||
isQuotasAllowed,
|
||||
}: SummaryPageProps) => {
|
||||
isReadOnly,
|
||||
}: Readonly<SummaryPageProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -225,6 +227,7 @@ export const SummaryPage = ({
|
||||
responseCount={surveySummary.meta.totalResponses}
|
||||
survey={surveyMemoized}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+21
-17
@@ -1111,23 +1111,27 @@ export const getResponsesForSummary = reactCache(
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TSurveySummaryResponse[] = responses.map((responsePrisma) => ({
|
||||
id: responsePrisma.id,
|
||||
data: (responsePrisma.data ?? {}) as TResponseData,
|
||||
updatedAt: responsePrisma.updatedAt,
|
||||
contact: responsePrisma.contact
|
||||
? {
|
||||
id: responsePrisma.contact.id as string,
|
||||
userId: responsePrisma.contact.attributes.find(
|
||||
(attribute) => attribute.attributeKey.key === "userId"
|
||||
)?.value as string,
|
||||
}
|
||||
: null,
|
||||
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
|
||||
language: responsePrisma.language,
|
||||
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
|
||||
finished: responsePrisma.finished,
|
||||
}));
|
||||
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
id: responsePrisma.id,
|
||||
data: (responsePrisma.data ?? {}) as TResponseData,
|
||||
updatedAt: responsePrisma.updatedAt,
|
||||
contact: responsePrisma.contact
|
||||
? {
|
||||
id: responsePrisma.contact.id as string,
|
||||
userId: responsePrisma.contact.attributes.find(
|
||||
(attribute) => attribute.attributeKey.key === "userId"
|
||||
)?.value as string,
|
||||
}
|
||||
: null,
|
||||
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
|
||||
language: responsePrisma.language,
|
||||
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
|
||||
finished: responsePrisma.finished,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
|
||||
+2
-1
@@ -22,7 +22,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const SurveyPage = async (props: { params: Promise<{ workspaceId: string; surveyId: string }> }) => {
|
||||
const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: string; surveyId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
@@ -88,6 +88,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
|
||||
|
||||
-4
@@ -11,7 +11,6 @@ import {
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
FlagIcon,
|
||||
GaugeIcon,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmilePlusIcon,
|
||||
SmartphoneIcon,
|
||||
StarIcon,
|
||||
User,
|
||||
@@ -105,8 +103,6 @@ const elementIcons = {
|
||||
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyElementTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
|
||||
[TSurveyElementTypeEnum.CES]: GaugeIcon,
|
||||
[TSurveyElementTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
|
||||
+27
-60
@@ -3,17 +3,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
|
||||
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockConstants = vi.hoisted(() => ({
|
||||
isFormbricksCloud: false,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
@@ -26,9 +21,7 @@ vi.mock("@formbricks/logger", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockConstants.isFormbricksCloud;
|
||||
},
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
@@ -44,15 +37,15 @@ vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/account/lib/account-deletion-audit", () => ({
|
||||
queueAccountDeletionAuditEvent: vi.fn(),
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetServerSession = vi.mocked(getServerSession);
|
||||
const mockLoggerError = vi.mocked(logger.error);
|
||||
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
|
||||
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
|
||||
const mockQueueAccountDeletionAuditEvent = vi.mocked(queueAccountDeletionAuditEvent);
|
||||
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
|
||||
|
||||
const intent = {
|
||||
id: "intent-id",
|
||||
@@ -64,10 +57,9 @@ const intent = {
|
||||
userId: "user-id",
|
||||
};
|
||||
|
||||
describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", () => {
|
||||
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConstants.isFormbricksCloud = false;
|
||||
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
@@ -79,22 +71,22 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
|
||||
oldUser: { id: intent.userId } as any,
|
||||
});
|
||||
mockQueueAccountDeletionAuditEvent.mockResolvedValue(undefined);
|
||||
mockQueueAuditEventBackground.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("returns login without deleting when the callback has no intent", async () => {
|
||||
await expect(completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({})).resolves.toBe(
|
||||
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
|
||||
"/auth/login"
|
||||
);
|
||||
|
||||
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockQueueAccountDeletionAuditEvent).not.toHaveBeenCalled();
|
||||
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deletes the account after a completed SSO identity confirmation", async () => {
|
||||
test("deletes the account after a completed SSO reauthentication", async () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
@@ -102,24 +94,15 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
userEmail: intent.email,
|
||||
userId: intent.userId,
|
||||
});
|
||||
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
|
||||
oldUser: { id: intent.userId },
|
||||
status: "success",
|
||||
targetUserId: intent.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("redirects to the account deletion survey after SSO identity confirmation on Formbricks Cloud", async () => {
|
||||
mockConstants.isFormbricksCloud = true;
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
confirmationEmail: intent.email,
|
||||
userEmail: intent.email,
|
||||
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: intent.userId,
|
||||
userType: "user",
|
||||
targetId: intent.userId,
|
||||
organizationId: "unknown",
|
||||
oldObject: { id: intent.userId },
|
||||
status: "success",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,43 +115,27 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(AuthorizationError) },
|
||||
"Failed to complete account deletion after SSO identity confirmation"
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns to the profile page with an error when deletion fails after SSO identity confirmation", async () => {
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockRejectedValue(
|
||||
new AuthorizationError("marker missing")
|
||||
);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
|
||||
status: "failure",
|
||||
targetUserId: intent.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
|
||||
mockQueueAccountDeletionAuditEvent.mockRejectedValue(new Error("audit unavailable"));
|
||||
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
"Failed to complete account deletion after SSO identity confirmation"
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -185,7 +152,7 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: ["intent-token"] })
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
|
||||
+20
-26
@@ -5,14 +5,11 @@ import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
|
||||
} from "@/modules/account/constants";
|
||||
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
type TAccountDeletionSsoCompleteSearchParams = {
|
||||
intent?: string | string[];
|
||||
@@ -26,7 +23,7 @@ const getIntentToken = (intent: string | string[] | undefined) => {
|
||||
return intent;
|
||||
};
|
||||
|
||||
const getSafeFailureRedirectPath = (returnToUrl: string) => {
|
||||
const getSafeRedirectPath = (returnToUrl: string) => {
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
@@ -34,23 +31,17 @@ const getSafeFailureRedirectPath = (returnToUrl: string) => {
|
||||
}
|
||||
|
||||
const parsedReturnToUrl = new URL(validatedReturnToUrl);
|
||||
parsedReturnToUrl.searchParams.set(
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
|
||||
);
|
||||
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
|
||||
};
|
||||
|
||||
const getPostDeletionRedirectPath = () =>
|
||||
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
|
||||
|
||||
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
|
||||
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
|
||||
intent,
|
||||
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
|
||||
const intentToken = getIntentToken(intent);
|
||||
let deletionSucceeded = false;
|
||||
let redirectPath = "/auth/login";
|
||||
let targetUserId: string | null = null;
|
||||
|
||||
if (!intentToken) {
|
||||
return redirectPath;
|
||||
@@ -58,30 +49,33 @@ export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath =
|
||||
|
||||
try {
|
||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
targetUserId = verifiedIntent.userId;
|
||||
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
|
||||
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
||||
throw new AuthorizationError("Account deletion SSO identity confirmation session mismatch");
|
||||
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
|
||||
}
|
||||
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail: verifiedIntent.email,
|
||||
userEmail: session.user.email,
|
||||
userId: session.user.id,
|
||||
});
|
||||
deletionSucceeded = true;
|
||||
redirectPath = getPostDeletionRedirectPath();
|
||||
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
|
||||
await queueAuditEventBackground({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: session.user.id,
|
||||
userType: "user",
|
||||
targetId: session.user.id,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
oldObject: oldUser,
|
||||
status: "success",
|
||||
});
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
|
||||
} catch (error) {
|
||||
if (targetUserId && !deletionSucceeded) {
|
||||
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
|
||||
}
|
||||
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
|
||||
}
|
||||
|
||||
return redirectPath;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
export default async function AccountDeletionSsoReauthCompletePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ intent?: string | string[] }>;
|
||||
}) {
|
||||
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
const getIntentSearchParam = (request: NextRequest): string | string[] | undefined => {
|
||||
const intentValues = request.nextUrl.searchParams.getAll("intent");
|
||||
|
||||
if (intentValues.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return intentValues.length === 1 ? intentValues[0] : intentValues;
|
||||
};
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const redirectPath = await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({
|
||||
intent: getIntentSearchParam(request),
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL(redirectPath, request.url));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const GET = async (req: Request) => {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
@@ -102,7 +102,7 @@ export const GET = async (req: Request) => {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
|
||||
return Response.redirect(`${WEBAPP_URL}/${basePath}/integrations/google-sheets`);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
|
||||
|
||||
@@ -313,18 +313,9 @@ describe("handleErrorResponse", () => {
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 404 notFound for ResourceNotFoundError", async () => {
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({
|
||||
code: "not_found",
|
||||
message: "Survey not found",
|
||||
details: {
|
||||
resource_id: "id-1",
|
||||
resource_type: "Survey",
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
|
||||
@@ -29,10 +29,11 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(error.resourceType, error.resourceId);
|
||||
}
|
||||
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
error instanceof ResourceNotFoundError
|
||||
) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Some error occurred");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -33,25 +32,7 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
const { workspaceId } = resolved;
|
||||
|
||||
let jsonInput;
|
||||
try {
|
||||
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Malformed JSON input, please check your request body",
|
||||
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZDisplayCreateInput.safeParse({
|
||||
...jsonInput,
|
||||
workspaceId,
|
||||
|
||||
@@ -103,7 +103,6 @@ describe("getWorkspaceStateData", () => {
|
||||
id: workspaceId,
|
||||
appSetupCompleted: true,
|
||||
workspaceSettings: {
|
||||
id: workspaceId,
|
||||
recontactDays: 30,
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
@@ -112,14 +111,7 @@ describe("getWorkspaceStateData", () => {
|
||||
styling: { allowStyleOverwrite: false },
|
||||
},
|
||||
},
|
||||
// `survey.name` is replaced with a back-compat placeholder; segment was
|
||||
// null in the mock so the sanitized segment stays null.
|
||||
surveys: [
|
||||
{
|
||||
...mockWorkspaceData.surveys[0],
|
||||
name: "[deprecated] survey name omitted from public API - will be removed soon",
|
||||
},
|
||||
],
|
||||
surveys: mockWorkspaceData.surveys,
|
||||
actionClasses: mockWorkspaceData.actionClasses,
|
||||
});
|
||||
|
||||
@@ -219,7 +211,6 @@ describe("getWorkspaceStateData", () => {
|
||||
const result = await getWorkspaceStateData(workspaceId);
|
||||
|
||||
expect(result.workspace.workspaceSettings).toEqual({
|
||||
id: workspaceId,
|
||||
recontactDays: 14,
|
||||
clickOutsideClose: false,
|
||||
overlay: "dark",
|
||||
|
||||
@@ -42,7 +42,6 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
legacyEnvironmentId: true,
|
||||
appSetupCompleted: true,
|
||||
recontactDays: true,
|
||||
clickOutsideClose: true,
|
||||
@@ -73,9 +72,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
// `name` deliberately not selected — internal label not needed by the
|
||||
// SDK and replaced with a fixed placeholder below so older SDKs that
|
||||
// decoded `Survey.name` as a required field keep working.
|
||||
// name intentionally omitted — internal label not needed by the SDK
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
@@ -102,9 +99,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
styling: true,
|
||||
status: true,
|
||||
recaptcha: true,
|
||||
// Only need to know if any filters exist so we can compute
|
||||
// `hasFilters`. Real filter values, segment title/description, and
|
||||
// surveys-list relation are never exposed to clients.
|
||||
// Fetch only what's needed to compute the minimal segment shape.
|
||||
// Titles, descriptions, and filter conditions are evaluated server-side
|
||||
// and must not be sent to the browser.
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -138,46 +135,17 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
throw new ResourceNotFoundError("workspace", workspaceId);
|
||||
}
|
||||
|
||||
// Backwards-compat response shape for SDKs from before PR #7931. Those
|
||||
// clients decoded `survey.name` and the full `segment` object as required
|
||||
// fields, so the response must still carry that shape — but every field
|
||||
// that could leak sensitive targeting data is replaced with a placeholder.
|
||||
// The actual segment-membership check happens server-side (segment IDs in
|
||||
// POST /user); SDKs only inspect `filters.length` / `hasFilters` locally.
|
||||
//
|
||||
// `environmentId` mirrors `legacyEnvironmentId ?? workspace.id`, matching
|
||||
// the `/me` endpoints' pattern so migrated workspaces keep returning the
|
||||
// original env ID older clients persisted.
|
||||
const legacyOrCurrentId = workspaceData.legacyEnvironmentId ?? workspaceData.id;
|
||||
const placeholderDate = new Date(0);
|
||||
const placeholderFilter = {
|
||||
id: "placeholder",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "placeholder",
|
||||
root: { type: "device", deviceType: "phone" },
|
||||
value: "deprecated",
|
||||
qualifier: { operator: "equals" },
|
||||
},
|
||||
};
|
||||
|
||||
// Transform surveys using the shared utility, then replace the segment with
|
||||
// the minimal public shape (id + hasFilters). We null out segment before
|
||||
// calling transformPrismaSurvey because that function expects a surveys[]
|
||||
// relation on the segment object (used by the management API), which we
|
||||
// intentionally don't fetch here.
|
||||
const transformedSurveys = workspaceData.surveys.map((survey) => {
|
||||
const realHasFilters =
|
||||
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
|
||||
|
||||
const sanitizedSegment = survey.segment
|
||||
const minimalSegment = survey.segment
|
||||
? {
|
||||
id: survey.segment.id,
|
||||
title: "[deprecated] segment title omitted from public API - will be removed soon",
|
||||
description: null,
|
||||
isPrivate: true,
|
||||
filters: realHasFilters ? [placeholderFilter] : [],
|
||||
environmentId: legacyOrCurrentId,
|
||||
workspaceId: legacyOrCurrentId,
|
||||
createdAt: placeholderDate,
|
||||
updatedAt: placeholderDate,
|
||||
surveys: [],
|
||||
hasFilters: realHasFilters,
|
||||
hasFilters:
|
||||
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -187,11 +155,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
segment: null,
|
||||
});
|
||||
|
||||
return {
|
||||
...transformed,
|
||||
name: "[deprecated] survey name omitted from public API - will be removed soon",
|
||||
segment: sanitizedSegment,
|
||||
};
|
||||
return { ...transformed, segment: minimalSegment };
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -199,7 +163,6 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
id: workspaceData.id,
|
||||
appSetupCompleted: workspaceData.appSetupCompleted,
|
||||
workspaceSettings: {
|
||||
id: workspaceData.id,
|
||||
recontactDays: workspaceData.recontactDays,
|
||||
clickOutsideClose: workspaceData.clickOutsideClose,
|
||||
overlay: workspaceData.overlay,
|
||||
@@ -208,11 +171,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
styling: resolveStorageUrlsInObject(workspaceData.styling),
|
||||
},
|
||||
},
|
||||
// The runtime shape carries extra back-compat fields (placeholder
|
||||
// segment, `hasFilters`, mirrored `environmentId`) that aren't part of
|
||||
// the modern `TJsWorkspaceStateSurvey`. Cast through unknown — this is
|
||||
// intentional and only this endpoint's response widens the type.
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys) as unknown as TJsWorkspaceStateSurvey[],
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||
actionClasses: workspaceData.actionClasses,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
-34
@@ -9,7 +9,6 @@ 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(),
|
||||
@@ -35,10 +34,6 @@ vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: mocks.getSurvey,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/resolve-client-id", () => ({
|
||||
resolveClientApiIds: mocks.resolveClientApiIds,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/validation", () => ({
|
||||
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
|
||||
validateResponseData: mocks.validateResponseData,
|
||||
@@ -128,7 +123,6 @@ 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);
|
||||
@@ -245,34 +239,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
-10
@@ -8,7 +8,6 @@ import { THandlerParams } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -210,7 +209,7 @@ export const putResponseHandler = async ({
|
||||
props,
|
||||
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
|
||||
const params = await props.params;
|
||||
const { workspaceId: workspaceIdParam, responseId } = params;
|
||||
const { workspaceId, responseId } = params;
|
||||
|
||||
if (!responseId) {
|
||||
return {
|
||||
@@ -218,14 +217,6 @@ export const putResponseHandler = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await resolveClientApiIds(workspaceIdParam);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Workspace", workspaceIdParam, true),
|
||||
};
|
||||
}
|
||||
const { workspaceId } = resolved;
|
||||
|
||||
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
|
||||
if ("response" in validatedUpdateInput) {
|
||||
return validatedUpdateInput;
|
||||
|
||||
@@ -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,21 +104,7 @@ 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,
|
||||
ttc
|
||||
);
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
@@ -147,13 +127,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");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -57,17 +56,11 @@ export const POST = withV1ApiWrapper({
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
responseInput = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Malformed JSON input, please check your request body",
|
||||
"Invalid JSON in request body",
|
||||
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
|
||||
true
|
||||
),
|
||||
@@ -218,7 +211,7 @@ export const POST = withV1ApiWrapper({
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (responseInputData.finished) {
|
||||
if (responseInput.finished) {
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSignedUrlForUpload } from "@/modules/storage/service";
|
||||
import { getErrorResponseFromStorageError, validateSurveyAllowsFileUpload } from "@/modules/storage/utils";
|
||||
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
@@ -107,23 +107,6 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const fileUploadPermission = validateSurveyAllowsFileUpload({
|
||||
fileName,
|
||||
blocks: survey.blocks,
|
||||
questions: survey.questions,
|
||||
});
|
||||
|
||||
if (!fileUploadPermission.ok) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
fileUploadPermission.reason === "no_file_upload_question"
|
||||
? "Survey does not allow file uploads"
|
||||
: "File extension is not allowed for this survey",
|
||||
undefined
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
|
||||
const maxFileUploadSize = isBiggerFileUploadAllowed
|
||||
? MAX_FILE_UPLOAD_SIZES.big
|
||||
|
||||
@@ -51,7 +51,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
|
||||
@@ -40,7 +40,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classe
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -85,14 +84,8 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
let actionClassUpdate;
|
||||
try {
|
||||
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
actionClassUpdate = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -46,14 +45,8 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
let actionClassInput;
|
||||
try {
|
||||
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
actionClassInput = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponseData, ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -13,11 +12,6 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
type TUncheckedResponseUpdate = Record<string, unknown> & {
|
||||
data: TResponseData;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
responseId: string,
|
||||
authentication: TApiV1Authentication | undefined,
|
||||
@@ -126,16 +120,10 @@ export const PUT = withV1ApiWrapper({
|
||||
auditLog.oldObject = result.response;
|
||||
}
|
||||
|
||||
let responseUpdate: TUncheckedResponseUpdate;
|
||||
let responseUpdate;
|
||||
try {
|
||||
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
|
||||
responseUpdate = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
|
||||
@@ -161,11 +161,15 @@ export const getResponsesByWorkspaceIds = reactCache(
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
}));
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
@@ -201,11 +205,15 @@ export const getResponses = reactCache(
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
}));
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -92,14 +91,8 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
let jsonInput;
|
||||
try {
|
||||
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
jsonInput = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
|
||||
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
|
||||
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -20,14 +19,8 @@ export const POST = withV1ApiWrapper({
|
||||
let storageInput;
|
||||
|
||||
try {
|
||||
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
storageInput = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
addLegacyProjectOverwrites,
|
||||
normaliseProjectOverwritesToWorkspace,
|
||||
} from "@/app/lib/api/api-backwards-compat";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
transformBlocksToQuestions,
|
||||
@@ -23,12 +22,6 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
|
||||
type TSurveyUpdateBody = Record<string, unknown> & {
|
||||
blocks?: Parameters<typeof validateSurveyInput>[0]["blocks"];
|
||||
endings?: Parameters<typeof transformQuestionsToBlocks>[1];
|
||||
questions?: Parameters<typeof transformQuestionsToBlocks>[0];
|
||||
};
|
||||
|
||||
const fetchAndAuthorizeSurvey = async (
|
||||
surveyId: string,
|
||||
authentication: TAuthenticationApiKey,
|
||||
@@ -171,16 +164,10 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
let surveyUpdate: TSurveyUpdateBody;
|
||||
let surveyUpdate;
|
||||
try {
|
||||
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
|
||||
surveyUpdate = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
@@ -201,7 +188,7 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
if (hasQuestions) {
|
||||
surveyUpdate.blocks = transformQuestionsToBlocks(
|
||||
surveyUpdate.questions ?? [],
|
||||
surveyUpdate.questions,
|
||||
surveyUpdate.endings || result.survey.endings
|
||||
);
|
||||
surveyUpdate.questions = [];
|
||||
@@ -221,11 +208,7 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const featureCheckResult = await checkFeaturePermissions(
|
||||
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
|
||||
organization,
|
||||
result.survey
|
||||
);
|
||||
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
|
||||
if (featureCheckResult) {
|
||||
return {
|
||||
response: featureCheckResult,
|
||||
|
||||
@@ -51,6 +51,7 @@ const mockOrganization: TOrganization = {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
};
|
||||
|
||||
const mockFollowUp: TSurveyCreateInputWithWorkspaceId["followUps"][number] = {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
addLegacyProjectOverwritesToList,
|
||||
normaliseProjectOverwritesToWorkspace,
|
||||
} from "@/app/lib/api/api-backwards-compat";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
transformBlocksToQuestions,
|
||||
@@ -85,14 +84,8 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
let surveyInput;
|
||||
try {
|
||||
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
surveyInput = await req.json();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
|
||||
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -41,14 +40,8 @@ export const POST = withV1ApiWrapper({
|
||||
|
||||
let webhookInput;
|
||||
try {
|
||||
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
webhookInput = await req.json();
|
||||
} catch {
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { doesContactExistInWorkspace } from "./contact";
|
||||
import { doesContactExist } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -21,25 +21,24 @@ vi.mock("react", async () => {
|
||||
});
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const workspaceId = "test-workspace-id";
|
||||
|
||||
describe("doesContactExistInWorkspace", () => {
|
||||
describe("doesContactExist", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return true if contact exists in the workspace", async () => {
|
||||
test("should return true if contact exists", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
|
||||
id: contactId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const result = await doesContactExistInWorkspace(contactId, workspaceId);
|
||||
const result = await doesContactExist(contactId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId, workspaceId },
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
@@ -47,11 +46,11 @@ describe("doesContactExistInWorkspace", () => {
|
||||
test("should return false if contact does not exist in the workspace", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await doesContactExistInWorkspace(contactId, workspaceId);
|
||||
const result = await doesContactExist(contactId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId, workspaceId },
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const doesContactExistInWorkspace = reactCache(
|
||||
async (id: string, workspaceId: string): Promise<boolean> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!contact;
|
||||
}
|
||||
);
|
||||
return !!contact;
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { TDisplayCreateInputV2 } from "../types/display";
|
||||
import { doesContactExistInWorkspace } from "./contact";
|
||||
import { doesContactExist } from "./contact";
|
||||
import { createDisplay } from "./display";
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
@@ -30,7 +30,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./contact", () => ({
|
||||
doesContactExistInWorkspace: vi.fn(),
|
||||
doesContactExist: vi.fn(),
|
||||
}));
|
||||
|
||||
const workspaceId = "workspace-id-mock";
|
||||
@@ -81,13 +81,13 @@ describe("createDisplay", () => {
|
||||
});
|
||||
|
||||
test("should create a display with contactId successfully", async () => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
|
||||
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -104,7 +104,7 @@ describe("createDisplay", () => {
|
||||
const result = await createDisplay(displayInputWithoutContact);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
|
||||
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
|
||||
expect(doesContactExist).not.toHaveBeenCalled();
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -115,13 +115,13 @@ describe("createDisplay", () => {
|
||||
});
|
||||
|
||||
test("should create a display without contact if contact does not exist in the workspace", async () => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(false);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(false);
|
||||
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
|
||||
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -139,16 +139,16 @@ describe("createDisplay", () => {
|
||||
});
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
|
||||
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
|
||||
expect(doesContactExist).not.toHaveBeenCalled();
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
|
||||
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: surveyId, workspaceId },
|
||||
});
|
||||
@@ -158,7 +158,7 @@ describe("createDisplay", () => {
|
||||
test.each(["draft", "paused", "completed"])(
|
||||
"should throw InvalidInputError when survey status is %s",
|
||||
async (status) => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
|
||||
@@ -171,7 +171,7 @@ describe("createDisplay", () => {
|
||||
code: "P2002",
|
||||
clientVersion: "2.0.0",
|
||||
});
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
|
||||
@@ -179,15 +179,15 @@ describe("createDisplay", () => {
|
||||
|
||||
test("should throw original error on other errors during creation", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should throw original error if doesContactExistInWorkspace fails", async () => {
|
||||
test("should throw original error if doesContactExist fails", async () => {
|
||||
const contactCheckError = new Error("Failed to check contact");
|
||||
vi.mocked(doesContactExistInWorkspace).mockRejectedValue(contactCheckError);
|
||||
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ZDisplayCreateInputV2,
|
||||
} from "@/app/api/v2/client/[workspaceId]/displays/types/display";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { doesContactExistInWorkspace } from "./contact";
|
||||
import { doesContactExist } from "./contact";
|
||||
|
||||
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
|
||||
validateInputs([displayInput, ZDisplayCreateInputV2]);
|
||||
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
|
||||
const { contactId, surveyId, workspaceId } = displayInput;
|
||||
|
||||
try {
|
||||
const contactExists = contactId ? await doesContactExistInWorkspace(contactId, workspaceId) : false;
|
||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
||||
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -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";
|
||||
@@ -55,7 +49,18 @@ const buildPrismaResponseData = (
|
||||
contact: { id: string; attributes: TContactAttributes } | null,
|
||||
ttc: Record<string, number>
|
||||
): Prisma.ResponseCreateInput => {
|
||||
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
|
||||
const {
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
|
||||
return {
|
||||
survey: {
|
||||
@@ -79,6 +84,8 @@ const buildPrismaResponseData = (
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -105,16 +112,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 +135,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");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
|
||||
import { withV3ApiWrapper } from "./api-wrapper";
|
||||
|
||||
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
|
||||
@@ -415,44 +414,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({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
|
||||
import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
type InvalidParam,
|
||||
problemBadRequest,
|
||||
problemInternalError,
|
||||
problemPayloadTooLarge,
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
} from "./response";
|
||||
@@ -172,15 +170,8 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
|
||||
let bodyData: unknown;
|
||||
|
||||
try {
|
||||
bodyData = await parseJsonBodyWithLimit(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemPayloadTooLarge(requestId, error.message, instance),
|
||||
};
|
||||
}
|
||||
|
||||
bodyData = await req.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid request body", {
|
||||
|
||||
@@ -71,17 +71,6 @@ export function problemBadRequest(
|
||||
});
|
||||
}
|
||||
|
||||
export function problemPayloadTooLarge(
|
||||
requestId: string,
|
||||
detail: string = "Payload Too Large",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(413, "Payload Too Large", detail, requestId, {
|
||||
code: "payload_too_large",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemUnauthorized(
|
||||
requestId: string,
|
||||
detail: string = "Not authenticated",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
|
||||
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "./request-body";
|
||||
|
||||
describe("parseAndValidateJsonBody", () => {
|
||||
test("returns a malformed JSON response when request parsing fails", async () => {
|
||||
@@ -40,40 +39,6 @@ describe("parseAndValidateJsonBody", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a payload too large response when the request body exceeds the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
const result = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.issue).toBe("payload_too_large");
|
||||
expect(result.response.status).toBe(413);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "payload_too_large",
|
||||
message: "Payload Too Large",
|
||||
details: {
|
||||
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a validation response when the parsed JSON does not match the schema", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
|
||||
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body" | "payload_too_large";
|
||||
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
|
||||
|
||||
type TJsonBodyValidationError = {
|
||||
details: Record<string, string> | { error: string };
|
||||
@@ -45,18 +44,10 @@ export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
|
||||
let jsonInput: unknown;
|
||||
|
||||
try {
|
||||
jsonInput = await parseJsonBodyWithLimit(request);
|
||||
jsonInput = await request.json();
|
||||
} catch (error) {
|
||||
const details = { error: getErrorMessage(error) };
|
||||
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
details,
|
||||
issue: "payload_too_large",
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", details, true),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
details,
|
||||
issue: "invalid_json",
|
||||
|
||||
@@ -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,90 +0,0 @@
|
||||
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export class RequestBodyTooLargeError extends Error {
|
||||
readonly actualBytes: number | null;
|
||||
readonly limitBytes: number;
|
||||
|
||||
constructor(limitBytes: number, actualBytes: number | null = null) {
|
||||
super(`Request body must not exceed ${limitBytes} bytes`);
|
||||
this.name = "RequestBodyTooLargeError";
|
||||
this.limitBytes = limitBytes;
|
||||
this.actualBytes = actualBytes;
|
||||
}
|
||||
}
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const getContentLength = (headers: Headers): number | null => {
|
||||
const contentLength = headers.get("content-length");
|
||||
if (!contentLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedContentLength = Number(contentLength);
|
||||
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedContentLength;
|
||||
};
|
||||
|
||||
const assertBodySize = (actualBytes: number, limitBytes: number): void => {
|
||||
if (actualBytes > limitBytes) {
|
||||
throw new RequestBodyTooLargeError(limitBytes, actualBytes);
|
||||
}
|
||||
};
|
||||
|
||||
export const readRequestBodyWithLimit = async (
|
||||
request: Request,
|
||||
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
|
||||
): Promise<string> => {
|
||||
const contentLength = getContentLength(request.headers);
|
||||
if (contentLength !== null) {
|
||||
assertBodySize(contentLength, limitBytes);
|
||||
}
|
||||
|
||||
if (!request.body) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let receivedBytes = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
receivedBytes += value.byteLength;
|
||||
if (receivedBytes > limitBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new RequestBodyTooLargeError(limitBytes, receivedBytes);
|
||||
}
|
||||
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (chunks.length === 1) {
|
||||
return textDecoder.decode(chunks[0]);
|
||||
}
|
||||
|
||||
const body = new Uint8Array(receivedBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return textDecoder.decode(body);
|
||||
};
|
||||
|
||||
export const parseJsonBodyWithLimit = async <TJson = unknown>(
|
||||
request: Request,
|
||||
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
|
||||
): Promise<TJson> => JSON.parse(await readRequestBodyWithLimit(request, limitBytes)) as TJson;
|
||||
@@ -17,8 +17,7 @@ interface ApiErrorResponse {
|
||||
| "not_authenticated"
|
||||
| "forbidden"
|
||||
| "too_many_requests"
|
||||
| "conflict"
|
||||
| "payload_too_large";
|
||||
| "conflict";
|
||||
message: string;
|
||||
details: {
|
||||
[key: string]: string | string[] | number | number[] | boolean | boolean[];
|
||||
@@ -81,30 +80,6 @@ const badRequestResponse = (
|
||||
);
|
||||
};
|
||||
|
||||
const payloadTooLargeResponse = (
|
||||
message: string = "Payload Too Large",
|
||||
details: ApiErrorResponse["details"] = {},
|
||||
cors: boolean = false,
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: "payload_too_large",
|
||||
message,
|
||||
details,
|
||||
},
|
||||
{
|
||||
status: 413,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const methodNotAllowedResponse = (
|
||||
res: CustomNextApiResponse,
|
||||
allowedMethods: string[],
|
||||
@@ -319,7 +294,6 @@ export const responses = {
|
||||
unauthorizedResponse,
|
||||
notFoundResponse,
|
||||
successResponse,
|
||||
payloadTooLargeResponse,
|
||||
tooManyRequestsResponse,
|
||||
forbiddenResponse,
|
||||
conflictResponse,
|
||||
|
||||
+7
-15
@@ -1602,15 +1602,13 @@ checksums:
|
||||
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
|
||||
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
|
||||
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
|
||||
workspace/analysis/charts/ai_enable_in_settings: 426cb4525381e193e6c4dcce286e60c8
|
||||
workspace/analysis/charts/ai_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
|
||||
workspace/analysis/charts/ai_not_available: 173abfcd32dd45edcc258dfdaaed494b
|
||||
workspace/analysis/charts/ai_not_enabled: 2066fe71ecf8994ba738c79b63a1934b
|
||||
workspace/analysis/charts/ai_not_in_plan: 4b75e143c97d657bd91f857ff2bbf33f
|
||||
workspace/analysis/charts/ai_not_enabled: 8651fdac58cd311d17a48001a880318d
|
||||
workspace/analysis/charts/ai_not_in_plan: 60bb0792a1ed98c07d8694029cdfdb43
|
||||
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
|
||||
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
|
||||
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
|
||||
workspace/analysis/charts/ai_upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
|
||||
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
|
||||
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
|
||||
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
|
||||
@@ -1859,7 +1857,6 @@ checksums:
|
||||
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
|
||||
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
|
||||
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
|
||||
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
|
||||
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
|
||||
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
|
||||
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
|
||||
@@ -1894,7 +1891,6 @@ checksums:
|
||||
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
|
||||
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
|
||||
@@ -2488,19 +2484,16 @@ checksums:
|
||||
workspace/settings/feedback_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_directories/no_access: 707627df25fbaa28f18aa0f0d03dcb81
|
||||
workspace/settings/feedback_directories/no_connectors: ccc725ff9a82a7b8ab68de735490a9b9
|
||||
workspace/settings/feedback_directories/no_unassigned_workspaces_description: c96a260b582e6c930de72e6e69f9a9a6
|
||||
workspace/settings/feedback_directories/no_unassigned_workspaces_title: 458d4289d73d799561bec26a0bb1a1a3
|
||||
workspace/settings/feedback_directories/pause_connectors_confirmation_description: 0e30f827576b931651b9eae44e00279b
|
||||
workspace/settings/feedback_directories/pause_connectors_confirmation_title: da1950dbb9ce62caa65c87ae8b88b1a1
|
||||
workspace/settings/feedback_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
workspace/settings/feedback_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
workspace/settings/feedback_directories/title: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
|
||||
workspace/settings/feedback_directories/unarchive_workspace_conflict: ed44bc0bd570b40de5251d04abf7bd08
|
||||
workspace/settings/feedback_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
|
||||
workspace/settings/feedback_directories/upgrade_prompt_description: eb8a4bf60bcae458899e1ea94094789d
|
||||
workspace/settings/feedback_directories/upgrade_prompt_title: 0a7b67ccf15a0aa8c64e5da7feb6e532
|
||||
workspace/settings/feedback_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
|
||||
workspace/settings/feedback_directories/workspace_assigned_to_directory: 6b907668667a9c74a99c437fa3cc2046
|
||||
workspace/settings/feedback_directories/workspaces_already_linked: ef6248289707611a44950c3406aec0ec
|
||||
workspace/settings/feedback_directories/workspaces_being_added: e01628710aff05c5172f2f43aab1f6fb
|
||||
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
@@ -2594,6 +2587,7 @@ checksums:
|
||||
workspace/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
|
||||
workspace/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
|
||||
workspace/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
|
||||
workspace/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
|
||||
workspace/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
|
||||
workspace/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
|
||||
workspace/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
|
||||
@@ -2604,8 +2598,8 @@ checksums:
|
||||
workspace/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
|
||||
workspace/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
|
||||
workspace/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
|
||||
workspace/settings/profile/sso_identity_confirmation_failed: 2d699f31f3e92bca9508a2772b071a1f
|
||||
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: 9a5d190ed96e0149ed431c130c40284d
|
||||
workspace/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
|
||||
workspace/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
|
||||
workspace/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
|
||||
workspace/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
|
||||
workspace/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
|
||||
@@ -2908,7 +2902,6 @@ checksums:
|
||||
workspace/surveys/edit/hidden_field_used_in_recall: 15d959528c3e817dce95640173d5d6a8
|
||||
workspace/surveys/edit/hidden_field_used_in_recall_ending_card: ea0d0b12ca1c9400690658cb1b537025
|
||||
workspace/surveys/edit/hidden_field_used_in_recall_welcome: bb498b6ee69c6311a3977d454866b610
|
||||
workspace/surveys/edit/hidden_fields_description: e9221cd00ae2944602c19ffbc82358a4
|
||||
workspace/surveys/edit/hide_back_button: 91355864b3032c3f57689074e2173544
|
||||
workspace/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
|
||||
workspace/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
|
||||
@@ -3210,7 +3203,6 @@ checksums:
|
||||
workspace/surveys/edit/variable_used_in_recall: 1979c231569117297d1a19972b349617
|
||||
workspace/surveys/edit/variable_used_in_recall_ending_card: e6ab9a124985708dd77067c014b7c514
|
||||
workspace/surveys/edit/variable_used_in_recall_welcome: 60b995389b488366d8f6f53df35b6d8d
|
||||
workspace/surveys/edit/variables_description: 4a55faa279acc675228f54dccf63db6a
|
||||
workspace/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
|
||||
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
|
||||
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
@@ -3454,6 +3446,7 @@ checksums:
|
||||
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
|
||||
workspace/surveys/summary/open_response_details: 0e5de115b5e605f68ea857cf8ef5533a
|
||||
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
|
||||
@@ -3522,7 +3515,6 @@ checksums:
|
||||
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
|
||||
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
|
||||
workspace/unify/api_ingestion_setup_description: d18a267d0e50198682950f5341307fa3
|
||||
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
|
||||
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
workspace/unify/clear_mapping: 9bd7c716667838b9f203f5af0ac2d651
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
|
||||
import {
|
||||
assertOrganizationAIConfigured,
|
||||
generateOrganizationAIText,
|
||||
getAISmartToolsUnavailableReason,
|
||||
getAIDataAnalysisUnavailableReason,
|
||||
getOrganizationAIConfig,
|
||||
isInstanceAIConfigured,
|
||||
} from "./service";
|
||||
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
|
||||
generateText: vi.fn(),
|
||||
isAiConfigured: vi.fn(),
|
||||
getOrganization: vi.fn(),
|
||||
getIsAIDataAnalysisEnabled: vi.fn(),
|
||||
getIsAISmartToolsEnabled: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
}));
|
||||
@@ -61,6 +62,7 @@ vi.mock("@/lib/organization/service", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
|
||||
}));
|
||||
|
||||
@@ -72,8 +74,10 @@ describe("AI organization service", () => {
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: "org_1",
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
|
||||
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("returns the instance AI status and organization settings", async () => {
|
||||
@@ -84,7 +88,9 @@ describe("AI organization service", () => {
|
||||
expect(result).toMatchObject({
|
||||
organizationId: "org_1",
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAISmartToolsEntitled: true,
|
||||
isAIDataAnalysisEntitled: true,
|
||||
isInstanceConfigured: true,
|
||||
});
|
||||
});
|
||||
@@ -98,22 +104,29 @@ describe("AI organization service", () => {
|
||||
test("fails closed when the organization is not entitled to AI", async () => {
|
||||
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
|
||||
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed when the requested AI capability is disabled", async () => {
|
||||
mocks.getOrganization.mockResolvedValueOnce({
|
||||
id: "org_1",
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
});
|
||||
|
||||
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
|
||||
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed when the instance AI configuration is incomplete", async () => {
|
||||
mocks.isAiConfigured.mockReturnValueOnce(false);
|
||||
|
||||
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
|
||||
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("generates organization AI text with the configured package abstraction", async () => {
|
||||
@@ -122,6 +135,7 @@ describe("AI organization service", () => {
|
||||
|
||||
const result = await generateOrganizationAIText({
|
||||
organizationId: "org_1",
|
||||
capability: "smartTools",
|
||||
prompt: "Translate this survey",
|
||||
});
|
||||
|
||||
@@ -145,12 +159,14 @@ describe("AI organization service", () => {
|
||||
await expect(
|
||||
generateOrganizationAIText({
|
||||
organizationId: "org_1",
|
||||
capability: "smartTools",
|
||||
prompt: "Translate this survey",
|
||||
})
|
||||
).rejects.toThrow(modelError);
|
||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
||||
{
|
||||
organizationId: "org_1",
|
||||
capability: "smartTools",
|
||||
isInstanceConfigured: true,
|
||||
errorCode: undefined,
|
||||
err: modelError,
|
||||
@@ -159,32 +175,34 @@ describe("AI organization service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("getAISmartToolsUnavailableReason", () => {
|
||||
describe("getAIDataAnalysisUnavailableReason", () => {
|
||||
const baseConfig = {
|
||||
organizationId: "org_1",
|
||||
isAISmartToolsEntitled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEntitled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
isInstanceConfigured: true,
|
||||
};
|
||||
|
||||
test("returns undefined when all checks pass", () => {
|
||||
expect(getAISmartToolsUnavailableReason(baseConfig)).toBeUndefined();
|
||||
expect(getAIDataAnalysisUnavailableReason(baseConfig)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns not_in_plan when smart tools entitlement is missing", () => {
|
||||
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEntitled: false })).toBe(
|
||||
test("returns not_in_plan when not entitled", () => {
|
||||
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEntitled: false })).toBe(
|
||||
"not_in_plan"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns not_enabled when smart tools is disabled at org level", () => {
|
||||
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEnabled: false })).toBe(
|
||||
test("returns not_enabled when disabled at org level", () => {
|
||||
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEnabled: false })).toBe(
|
||||
"not_enabled"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns instance_not_configured when instance AI is missing", () => {
|
||||
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
|
||||
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
|
||||
"instance_not_configured"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,11 +4,12 @@ import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
export const AI_ERROR_CODES = {
|
||||
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
|
||||
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
|
||||
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
|
||||
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
|
||||
} as const;
|
||||
|
||||
@@ -17,7 +18,9 @@ export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
|
||||
export interface TOrganizationAIConfig {
|
||||
organizationId: string;
|
||||
isAISmartToolsEnabled: boolean;
|
||||
isAIDataAnalysisEnabled: boolean;
|
||||
isAISmartToolsEntitled: boolean;
|
||||
isAIDataAnalysisEntitled: boolean;
|
||||
isInstanceConfigured: boolean;
|
||||
}
|
||||
|
||||
@@ -30,40 +33,52 @@ export const getOrganizationAIConfig = async (organizationId: string): Promise<T
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isAISmartToolsEntitled = await getIsAISmartToolsEnabled(organizationId);
|
||||
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
|
||||
getIsAISmartToolsEnabled(organizationId),
|
||||
getIsAIDataAnalysisEnabled(organizationId),
|
||||
]);
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
|
||||
isAISmartToolsEntitled,
|
||||
isAIDataAnalysisEntitled,
|
||||
isInstanceConfigured: isInstanceAIConfigured(),
|
||||
};
|
||||
};
|
||||
|
||||
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
|
||||
|
||||
export const getAISmartToolsUnavailableReason = (
|
||||
export const getAIDataAnalysisUnavailableReason = (
|
||||
aiConfig: TOrganizationAIConfig
|
||||
): TAIUnavailableReason | undefined => {
|
||||
if (!aiConfig.isAISmartToolsEntitled) return "not_in_plan";
|
||||
if (!aiConfig.isAISmartToolsEnabled) return "not_enabled";
|
||||
if (!aiConfig.isAIDataAnalysisEntitled) return "not_in_plan";
|
||||
if (!aiConfig.isAIDataAnalysisEnabled) return "not_enabled";
|
||||
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const assertOrganizationAIConfigured = async (
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
capability: "smartTools" | "dataAnalysis"
|
||||
): Promise<TOrganizationAIConfig> => {
|
||||
const aiConfig = await getOrganizationAIConfig(organizationId);
|
||||
const isCapabilityEntitled =
|
||||
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
|
||||
|
||||
if (!aiConfig.isAISmartToolsEntitled) {
|
||||
if (!isCapabilityEntitled) {
|
||||
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
|
||||
}
|
||||
|
||||
if (!aiConfig.isAISmartToolsEnabled) {
|
||||
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
|
||||
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
|
||||
}
|
||||
|
||||
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
|
||||
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
|
||||
}
|
||||
|
||||
if (!aiConfig.isInstanceConfigured) {
|
||||
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
|
||||
}
|
||||
@@ -73,13 +88,15 @@ export const assertOrganizationAIConfigured = async (
|
||||
|
||||
type TGenerateOrganizationAITextInput = {
|
||||
organizationId: string;
|
||||
capability: "smartTools" | "dataAnalysis";
|
||||
} & Parameters<typeof generateText>[0];
|
||||
|
||||
export const generateOrganizationAIText = async ({
|
||||
organizationId,
|
||||
capability,
|
||||
...options
|
||||
}: TGenerateOrganizationAITextInput): Promise<Awaited<ReturnType<typeof generateText>>> => {
|
||||
const aiConfig = await assertOrganizationAIConfigured(organizationId);
|
||||
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
|
||||
|
||||
try {
|
||||
return await generateText(options, env);
|
||||
@@ -87,6 +104,7 @@ export const generateOrganizationAIText = async ({
|
||||
logger.error(
|
||||
{
|
||||
organizationId,
|
||||
capability,
|
||||
isInstanceConfigured: aiConfig.isInstanceConfigured,
|
||||
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
|
||||
err: error,
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -25,8 +25,7 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION =
|
||||
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "1";
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
@@ -34,6 +33,7 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
|
||||
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
|
||||
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
|
||||
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
|
||||
@@ -237,4 +237,4 @@ export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
|
||||
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
|
||||
|
||||
// Control hash for constant-time password verification to prevent timing attacks. Used when user doesn't exist to maintain consistent verification timing
|
||||
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q"; //NOSONAR not a real password hash, used only for timing-safe comparison
|
||||
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+4
-2
@@ -153,7 +153,7 @@ const parsedEnv = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: z.enum(["1", "0"]).optional(),
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
// DEBUG is a common ambient env var in CI/tooling, so we accept arbitrary strings here
|
||||
@@ -173,6 +173,7 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_PROJECT: z.string().optional(),
|
||||
@@ -314,7 +315,7 @@ const parsedEnv = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: process.env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
@@ -332,6 +333,7 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
GITHUB_ID: process.env.GITHUB_ID,
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
AI_GOOGLE_CLOUD_PROJECT: process.env.AI_GOOGLE_CLOUD_PROJECT,
|
||||
|
||||
@@ -1126,7 +1126,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("account deletion SSO identity confirmation intents", () => {
|
||||
describe("account deletion SSO reauthentication intents", () => {
|
||||
const accountDeletionIntent = {
|
||||
id: "intent-id",
|
||||
userId: mockUser.id,
|
||||
@@ -1137,7 +1137,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
|
||||
};
|
||||
|
||||
test("round-trips encrypted account deletion SSO identity confirmation intents", () => {
|
||||
test("round-trips encrypted account deletion reauth intents", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
|
||||
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
|
||||
@@ -1154,14 +1154,14 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("creates account deletion SSO identity confirmation intents with a ten minute default expiry", () => {
|
||||
test("creates account deletion reauth intents with a ten minute default expiry", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
|
||||
expect(decoded.exp - decoded.iat).toBe(10 * 60);
|
||||
});
|
||||
|
||||
test("rejects account deletion SSO identity confirmation intents with the wrong purpose", () => {
|
||||
test("rejects account deletion reauth intents with the wrong purpose", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
@@ -1183,7 +1183,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects account deletion SSO identity confirmation intents missing required fields", () => {
|
||||
test("rejects account deletion reauth intents missing required fields", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
@@ -1204,7 +1204,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects expired account deletion SSO identity confirmation intents", () => {
|
||||
test("rejects expired account deletion reauth intents", () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
@@ -1225,7 +1225,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
|
||||
});
|
||||
|
||||
test("throws when account deletion SSO identity confirmation intent secrets are missing", async () => {
|
||||
test("throws when account deletion reauth intent secrets are missing", async () => {
|
||||
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
|
||||
|
||||
const token = jwt.sign(
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("auth", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
|
||||
|
||||
@@ -46,13 +46,6 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
|
||||
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
deleteHubTenantData: vi.fn().mockResolvedValue({
|
||||
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Organization Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
|
||||
@@ -80,6 +73,7 @@ describe("Organization Service", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -132,6 +126,7 @@ describe("Organization Service", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
},
|
||||
];
|
||||
@@ -184,6 +179,7 @@ describe("Organization Service", () => {
|
||||
updatedAt: new Date(),
|
||||
billing: expectedBilling,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -243,6 +239,7 @@ describe("Organization Service", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user1" }, { userId: "user2" }],
|
||||
workspaces: [
|
||||
@@ -284,6 +281,7 @@ describe("Organization Service", () => {
|
||||
usageCycleAnchor: expect.any(Date),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
});
|
||||
expect(prisma.organization.update).toHaveBeenCalledWith({
|
||||
@@ -357,7 +355,6 @@ describe("Organization Service", () => {
|
||||
billing: { stripeCustomerId: "cus_123" },
|
||||
memberships: [],
|
||||
workspaces: [],
|
||||
feedbackDirectories: [],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
@@ -366,23 +363,5 @@ describe("Organization Service", () => {
|
||||
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
|
||||
}
|
||||
});
|
||||
|
||||
test("should purge Hub-owned data for each feedback directory", async () => {
|
||||
const { deleteHubTenantData } = await import("@/modules/hub/service");
|
||||
vi.mocked(prisma.organization.delete).mockResolvedValue({
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
billing: null,
|
||||
memberships: [],
|
||||
workspaces: [],
|
||||
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
|
||||
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
|
||||
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
|
||||
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ import { updateUser } from "@/lib/user/service";
|
||||
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
|
||||
import { getWorkspaces } from "@/lib/workspace/service";
|
||||
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { deleteHubTenantData } from "@/modules/hub/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const select = {
|
||||
@@ -36,6 +35,7 @@ export const select = {
|
||||
},
|
||||
},
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
whitelabel: true,
|
||||
} satisfies Prisma.OrganizationSelect;
|
||||
|
||||
@@ -74,6 +74,7 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
|
||||
name: organization.name,
|
||||
billing: mapOrganizationBilling(organization.billing),
|
||||
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
|
||||
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
|
||||
});
|
||||
|
||||
@@ -293,11 +294,6 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
feedbackDirectories: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -305,13 +301,6 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
|
||||
await cleanupStripeCustomer(stripeCustomerId);
|
||||
}
|
||||
|
||||
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) for each
|
||||
// directory tenant. Failures are logged inside the gateway and do not roll back the
|
||||
// local delete.
|
||||
for (const directory of deletedOrganization.feedbackDirectories) {
|
||||
await deleteHubTenantData(directory.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { updateResponse } from "./service";
|
||||
@@ -325,35 +324,5 @@ describe("updateResponse", () => {
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -217,6 +216,43 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
|
||||
}
|
||||
});
|
||||
|
||||
export const getResponseWithQuotas = reactCache(
|
||||
async (responseId: string): Promise<TResponseWithQuotas | null> => {
|
||||
validateInputs([responseId, ZId]);
|
||||
|
||||
try {
|
||||
const responsePrisma = await prisma.response.findUnique({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
select: {
|
||||
...responseSelection,
|
||||
quotaLinks: {
|
||||
where: { status: "screenedIn" },
|
||||
include: { quota: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!responsePrisma) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { quotaLinks, ...rest } = responsePrisma;
|
||||
return {
|
||||
...mapResponsePrismaToResponse(rest),
|
||||
quotas: quotaLinks.map((ql) => ql.quota),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
|
||||
validateInputs([responseId, ZId]);
|
||||
|
||||
@@ -338,15 +374,17 @@ export const getResponses = reactCache(
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponseWithQuotas[] = responses.map((responsePrisma) => {
|
||||
const { quotaLinks, ...response } = responsePrisma;
|
||||
return {
|
||||
...response,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
|
||||
};
|
||||
});
|
||||
const transformedResponses: TResponseWithQuotas[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
const { quotaLinks, ...response } = responsePrisma;
|
||||
return {
|
||||
...response,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
@@ -568,13 +606,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getResponseBySingleUseId,
|
||||
getResponseCountBySurveyId,
|
||||
getResponseDownloadFile,
|
||||
getResponseWithQuotas,
|
||||
getResponsesByWorkspaceId,
|
||||
responseSelection,
|
||||
updateResponse,
|
||||
@@ -170,6 +171,70 @@ describe("Tests for getResponse service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getResponseWithQuotas service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Returns the response with screened-in quotas", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue(mockResponseWithQuotas);
|
||||
|
||||
const result = await getResponseWithQuotas(mockResponseWithQuotas.id);
|
||||
|
||||
expect(result).toEqual({
|
||||
...expectedResponseWithoutPerson,
|
||||
quotas: mockResponseWithQuotas.quotaLinks.map(
|
||||
(ql: { quota: { id: string; name: string } }) => ql.quota
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("Returns an empty quotas array when no quotaLinks are screened in", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
|
||||
|
||||
const result = await getResponseWithQuotas(mockResponse.id);
|
||||
|
||||
expect(result).toEqual({ ...expectedResponseWithoutPerson, quotas: [] });
|
||||
});
|
||||
|
||||
test("Selects only screened-in quotaLinks", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
|
||||
|
||||
await getResponseWithQuotas(mockResponse.id);
|
||||
|
||||
const findUniqueCall = prisma.response.findUnique.mock.calls.at(-1)?.[0];
|
||||
expect(findUniqueCall?.select?.quotaLinks).toEqual({
|
||||
where: { status: "screenedIn" },
|
||||
include: { quota: { select: { id: true, name: true } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponseWithQuotas, "123#");
|
||||
|
||||
test("Returns null when no response is found", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getResponseWithQuotas(mockResponse.id);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
prisma.response.findUnique.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Rethrows generic errors", async () => {
|
||||
prisma.response.findUnique.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getSurveySummary service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Returns a summary of the survey responses", async () => {
|
||||
|
||||
@@ -228,6 +228,7 @@ export const mockOrganizationOutput: TOrganization = {
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("User Service", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "org2",
|
||||
@@ -84,6 +85,7 @@ describe("User Service", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
ConfigurationError,
|
||||
EXPECTED_ERROR_NAMES,
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidInputError,
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
ValidationError,
|
||||
isExpectedError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { RequestBodyTooLargeError } from "@/app/lib/api/request-body";
|
||||
|
||||
// Mock Sentry
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
@@ -75,11 +75,11 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"ValidationError",
|
||||
"AuthenticationError",
|
||||
"OperationNotAllowedError",
|
||||
"ConfigurationError",
|
||||
"QueryExecutionError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
"UniqueConstraintError",
|
||||
"RequestBodyTooLargeError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -96,10 +96,10 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: ConfigurationError, args: ["Cube is not configured"] },
|
||||
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
|
||||
{ ErrorClass: RequestBodyTooLargeError, args: [2 * 1024 * 1024] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
@@ -188,6 +188,12 @@ describe("actionClient handleServerError", () => {
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ConfigurationError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(new ConfigurationError("Cube is not configured"));
|
||||
expect(result?.serverError).toBe("Cube is not configured");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("QueryExecutionError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new QueryExecutionError("Cube query failed. Details: connect ECONNREFUSED")
|
||||
|
||||
@@ -38,50 +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", () => {
|
||||
@@ -104,54 +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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,30 +2,11 @@ import { AsyncParser } from "@json2csv/node";
|
||||
import * as xlsx from "xlsx";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
// Defang spreadsheet formula injection. Cell values starting with
|
||||
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
|
||||
// Sanitize at the render boundary only — never rewrite row keys, since
|
||||
// distinct user-controlled labels could collide after prefixing (e.g.
|
||||
// "=field" and "'=field" both map to "'=field"), dropping cell data.
|
||||
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
|
||||
|
||||
const sanitizeFormulaInjection = <T>(value: T): T => {
|
||||
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
|
||||
return `'${value}` as T;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
|
||||
let csv: string = "";
|
||||
|
||||
// Field descriptors preserve the original lookup key while overriding the
|
||||
// rendered label and cell value with sanitized versions.
|
||||
const parser = new AsyncParser({
|
||||
fields: fields.map((name) => ({
|
||||
label: sanitizeFormulaInjection(name),
|
||||
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
|
||||
})),
|
||||
fields,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -42,13 +23,8 @@ export const convertToXlsxBuffer = (
|
||||
fields: string[],
|
||||
jsonData: Record<string, string | number>[]
|
||||
): Buffer => {
|
||||
// Build as array-of-arrays so original row keys are looked up before
|
||||
// sanitization is applied to the rendered header/cell only.
|
||||
const headerRow = fields.map(sanitizeFormulaInjection);
|
||||
const dataRows = jsonData.map((row) => fields.map((name) => sanitizeFormulaInjection(row[name])));
|
||||
|
||||
const wb = xlsx.utils.book_new();
|
||||
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
|
||||
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("promises utilities", () => {
|
||||
const promise = delay(delayTime);
|
||||
|
||||
vi.advanceTimersByTime(delayTime);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
await promise;
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user