mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 11:38:38 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78170d2c67 |
@@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
-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) {
|
||||
|
||||
+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";
|
||||
|
||||
@@ -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,
|
||||
@@ -27,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(
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
-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,
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -32,7 +33,19 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
const { workspaceId } = resolved;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -104,11 +104,7 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData = buildPrismaResponseData(
|
||||
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
|
||||
contact,
|
||||
ttc
|
||||
);
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -56,8 +57,14 @@ export const POST = withV1ApiWrapper({
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await req.json();
|
||||
responseInput = 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(
|
||||
"Invalid JSON in request body",
|
||||
@@ -211,7 +218,7 @@ export const POST = withV1ApiWrapper({
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
if (responseInputData.finished) {
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -84,8 +85,14 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
let actionClassUpdate;
|
||||
try {
|
||||
actionClassUpdate = await req.json();
|
||||
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} 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,6 +2,7 @@ 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";
|
||||
@@ -45,8 +46,14 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
let actionClassInput;
|
||||
try {
|
||||
actionClassInput = await req.json();
|
||||
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} 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,6 +1,7 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TResponseData, 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";
|
||||
@@ -12,6 +13,11 @@ 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,
|
||||
@@ -120,10 +126,16 @@ export const PUT = withV1ApiWrapper({
|
||||
auditLog.oldObject = result.response;
|
||||
}
|
||||
|
||||
let responseUpdate;
|
||||
let responseUpdate: TUncheckedResponseUpdate;
|
||||
try {
|
||||
responseUpdate = await req.json();
|
||||
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
|
||||
} 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,6 +2,7 @@ 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";
|
||||
@@ -91,8 +92,14 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
let jsonInput;
|
||||
try {
|
||||
jsonInput = await req.json();
|
||||
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} 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,6 +2,7 @@ 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";
|
||||
@@ -19,8 +20,14 @@ export const POST = withV1ApiWrapper({
|
||||
let storageInput;
|
||||
|
||||
try {
|
||||
storageInput = await req.json();
|
||||
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} 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,6 +9,7 @@ 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,
|
||||
@@ -22,6 +23,12 @@ 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,
|
||||
@@ -164,10 +171,16 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
let surveyUpdate;
|
||||
let surveyUpdate: TSurveyUpdateBody;
|
||||
try {
|
||||
surveyUpdate = await req.json();
|
||||
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
|
||||
} 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"),
|
||||
@@ -188,7 +201,7 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
if (hasQuestions) {
|
||||
surveyUpdate.blocks = transformQuestionsToBlocks(
|
||||
surveyUpdate.questions,
|
||||
surveyUpdate.questions!,
|
||||
surveyUpdate.endings || result.survey.endings
|
||||
);
|
||||
surveyUpdate.questions = [];
|
||||
@@ -208,7 +221,11 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
|
||||
const featureCheckResult = await checkFeaturePermissions(
|
||||
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
|
||||
organization,
|
||||
result.survey
|
||||
);
|
||||
if (featureCheckResult) {
|
||||
return {
|
||||
response: featureCheckResult,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -84,8 +85,14 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
let surveyInput;
|
||||
try {
|
||||
surveyInput = await req.json();
|
||||
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} 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,6 +2,7 @@ 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";
|
||||
@@ -40,8 +41,14 @@ export const POST = withV1ApiWrapper({
|
||||
|
||||
let webhookInput;
|
||||
try {
|
||||
webhookInput = await req.json();
|
||||
} catch {
|
||||
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
|
||||
@@ -49,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: {
|
||||
@@ -73,6 +84,8 @@ const buildPrismaResponseData = (
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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(() => ({
|
||||
@@ -414,6 +415,44 @@ 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,6 +4,7 @@ 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";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
type InvalidParam,
|
||||
problemBadRequest,
|
||||
problemInternalError,
|
||||
problemPayloadTooLarge,
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
} from "./response";
|
||||
@@ -170,8 +172,15 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
|
||||
let bodyData: unknown;
|
||||
|
||||
try {
|
||||
bodyData = await req.json();
|
||||
} catch {
|
||||
bodyData = await parseJsonBodyWithLimit(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemPayloadTooLarge(requestId, error.message, instance),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid request body", {
|
||||
|
||||
@@ -71,6 +71,17 @@ 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,6 +1,7 @@
|
||||
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 () => {
|
||||
@@ -39,6 +40,40 @@ 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,8 +1,9 @@
|
||||
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";
|
||||
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body" | "payload_too_large";
|
||||
|
||||
type TJsonBodyValidationError = {
|
||||
details: Record<string, string> | { error: string };
|
||||
@@ -44,10 +45,18 @@ export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
|
||||
let jsonInput: unknown;
|
||||
|
||||
try {
|
||||
jsonInput = await request.json();
|
||||
jsonInput = await parseJsonBodyWithLimit(request);
|
||||
} 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",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
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,7 +17,8 @@ interface ApiErrorResponse {
|
||||
| "not_authenticated"
|
||||
| "forbidden"
|
||||
| "too_many_requests"
|
||||
| "conflict";
|
||||
| "conflict"
|
||||
| "payload_too_large";
|
||||
message: string;
|
||||
details: {
|
||||
[key: string]: string | string[] | number | number[] | boolean | boolean[];
|
||||
@@ -80,6 +81,30 @@ 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,
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 413,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const methodNotAllowedResponse = (
|
||||
res: CustomNextApiResponse,
|
||||
allowedMethods: string[],
|
||||
@@ -294,6 +319,7 @@ export const responses = {
|
||||
unauthorizedResponse,
|
||||
notFoundResponse,
|
||||
successResponse,
|
||||
payloadTooLargeResponse,
|
||||
tooManyRequestsResponse,
|
||||
forbiddenResponse,
|
||||
conflictResponse,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
@@ -73,10 +74,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||
|
||||
if (schemas?.body) {
|
||||
let bodyData;
|
||||
let bodyData: Record<string, unknown>;
|
||||
try {
|
||||
bodyData = await request.json();
|
||||
bodyData = await parseJsonBodyWithLimit<Record<string, unknown>>(request);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return handleApiError(request, {
|
||||
type: "payload_too_large",
|
||||
details: [
|
||||
{
|
||||
field: "body",
|
||||
issue: error.message,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
@@ -164,6 +165,42 @@ describe("apiWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle oversized JSON input in request body", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 413 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||
type: "payload_too_large",
|
||||
details: [
|
||||
{
|
||||
field: "body",
|
||||
issue: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty body when body schema is provided", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
|
||||
@@ -148,6 +148,35 @@ const conflictResponse = ({
|
||||
);
|
||||
};
|
||||
|
||||
const payloadTooLargeResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 413,
|
||||
message: "Payload Too Large",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 413,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const unprocessableEntityResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
@@ -351,6 +380,7 @@ export const responses = {
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
conflictResponse,
|
||||
payloadTooLargeResponse,
|
||||
unprocessableEntityResponse,
|
||||
tooManyRequestsResponse,
|
||||
internalServerErrorResponse,
|
||||
|
||||
@@ -28,6 +28,8 @@ export const handleApiError = (
|
||||
return responses.notFoundResponse({ details: err.details });
|
||||
case "conflict":
|
||||
return responses.conflictResponse({ details: err.details });
|
||||
case "payload_too_large":
|
||||
return responses.payloadTooLargeResponse({ details: err.details });
|
||||
case "unprocessable_entity":
|
||||
return responses.unprocessableEntityResponse({ details: err.details });
|
||||
case "too_many_requests":
|
||||
|
||||
@@ -10,7 +10,13 @@ export type ApiErrorDetails = {
|
||||
|
||||
export type ApiErrorResponseV2 =
|
||||
| {
|
||||
type: "unauthorized" | "forbidden" | "conflict" | "too_many_requests" | "internal_server_error";
|
||||
type:
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "conflict"
|
||||
| "payload_too_large"
|
||||
| "too_many_requests"
|
||||
| "internal_server_error";
|
||||
details?: ApiErrorDetails;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
@@ -8,17 +6,6 @@ import {
|
||||
getFilterOperatorsForType,
|
||||
} from "./schema-definition";
|
||||
|
||||
const chartCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
const dockerCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
|
||||
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
|
||||
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
|
||||
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
|
||||
|
||||
describe("schema-definition", () => {
|
||||
describe("getFilterOperatorsForType", () => {
|
||||
test("returns string operators", () => {
|
||||
@@ -107,20 +94,5 @@ describe("schema-definition", () => {
|
||||
);
|
||||
expect(ids).not.toContain("FeedbackRecords.averageScore");
|
||||
});
|
||||
|
||||
test("only exposes members present in the deployed Cube schema", () => {
|
||||
const chartCubeSchema = readChartCubeSchema();
|
||||
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
|
||||
getCubeMemberName(id)
|
||||
);
|
||||
|
||||
for (const member of exposedMembers) {
|
||||
expect(chartCubeSchema).toContain(` ${member}: {`);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the Helm and Docker Cube schemas in sync", () => {
|
||||
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { webhookHandler } from "@/modules/ee/billing/api/lib/stripe-webhook";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
try {
|
||||
const body = await request.text();
|
||||
const body = await readRequestBodyWithLimit(request);
|
||||
const requestHeaders = await headers(); // Corrected: headers() is async
|
||||
const signature = requestHeaders.get("stripe-signature");
|
||||
|
||||
@@ -26,6 +27,10 @@ export const POST = async (request: Request) => {
|
||||
|
||||
return NextResponse.json(result.message || { received: true }, { status: 200 });
|
||||
} catch (error: any) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return NextResponse.json({ message: "Payload Too Large" }, { status: 413 });
|
||||
}
|
||||
|
||||
logger.error(error, `Unhandled error in Stripe webhook POST handler: ${error.message}`);
|
||||
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ZId } from "@formbricks/types/common";
|
||||
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
@@ -27,6 +28,11 @@ const handleError = (err: unknown, url: string): { response: Response; error?: u
|
||||
};
|
||||
};
|
||||
|
||||
type TContactUserRequestBody = Record<string, unknown> & {
|
||||
attributes?: Record<string, unknown>;
|
||||
userId?: unknown;
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
{},
|
||||
@@ -76,7 +82,18 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
const { workspaceId } = resolved;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
let jsonInput: TContactUserRequestBody;
|
||||
try {
|
||||
jsonInput = await parseJsonBodyWithLimit<TContactUserRequestBody>(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Basic input validation without Zod overhead
|
||||
if (
|
||||
@@ -91,8 +108,13 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
// Simple email validation if present (avoid Zod)
|
||||
if (jsonInput.attributes?.email) {
|
||||
const email = jsonInput.attributes.email;
|
||||
const attributes =
|
||||
typeof jsonInput.attributes === "object" && jsonInput.attributes !== null
|
||||
? jsonInput.attributes
|
||||
: undefined;
|
||||
|
||||
if (attributes?.email) {
|
||||
const email = attributes.email;
|
||||
if (typeof email !== "string" || !email.includes("@") || email.length < 3) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid email format", undefined, true),
|
||||
@@ -100,7 +122,7 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
}
|
||||
|
||||
const { userId, attributes } = jsonInput;
|
||||
const userId = jsonInput.userId;
|
||||
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
|
||||
+8
-1
@@ -1,5 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -149,8 +150,14 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
let contactAttributeKeyUpdate;
|
||||
try {
|
||||
contactAttributeKeyUpdate = await req.json();
|
||||
contactAttributeKeyUpdate = await parseJsonBodyWithLimit(req);
|
||||
} 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,6 +1,7 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -63,8 +64,14 @@ export const POST = withV1ApiWrapper({
|
||||
|
||||
let contactAttributeKeyInput;
|
||||
try {
|
||||
contactAttributeKeyInput = await req.json();
|
||||
contactAttributeKeyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { verifyFeedbackRecordsGatewayToken } from "@/lib/jwt";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
@@ -147,17 +148,35 @@ const parseTenantId = (tenantId: string | null): string | null => {
|
||||
return ZId.safeParse(tenantId).success ? tenantId : null;
|
||||
};
|
||||
|
||||
const parseJsonBody = async (request: NextRequest): Promise<Record<string, unknown> | null> => {
|
||||
const parseJsonBody = async (
|
||||
request: NextRequest
|
||||
): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
body: Record<string, unknown> | null;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
response: Response;
|
||||
}
|
||||
> => {
|
||||
try {
|
||||
const rawBody = await request.text();
|
||||
const rawBody = await readRequestBodyWithLimit(request);
|
||||
if (!rawBody.trim()) {
|
||||
return null;
|
||||
return { ok: true, body: null };
|
||||
}
|
||||
|
||||
const parsedBody = JSON.parse(rawBody);
|
||||
return parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
return {
|
||||
ok: true,
|
||||
body: parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return { ok: false, response: buildGatewayStatusResponse(413, "Payload Too Large") };
|
||||
}
|
||||
|
||||
return { ok: true, body: null };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,7 +227,12 @@ const resolveTenantId = async (
|
||||
}
|
||||
|
||||
if (route.tenantSource === "body") {
|
||||
const body = await parseJsonBody(request);
|
||||
const parseResult = await parseJsonBody(request);
|
||||
if (!parseResult.ok) {
|
||||
return { errorResponse: parseResult.response };
|
||||
}
|
||||
|
||||
const body = parseResult.body;
|
||||
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
|
||||
if (!tenantId) {
|
||||
return {
|
||||
|
||||
@@ -5,14 +5,9 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface GoBackButtonProps {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
|
||||
export const GoBackButton = ({ url }: { url?: string }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -22,7 +17,6 @@ export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
}}>
|
||||
<ArrowLeftIcon />
|
||||
|
||||
@@ -9,120 +9,46 @@ cube(`FeedbackRecords`, {
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
uniqueRespondents: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.user_id`,
|
||||
description: `Number of unique users who provided feedback`,
|
||||
},
|
||||
|
||||
uniqueResponses: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.submission_id`,
|
||||
description: `Number of unique survey submissions (a submission can produce multiple feedback records)`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
|
||||
description: `Number of NPS promoters (score 9-10)`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
|
||||
description: `Number of NPS detractors (score 0-6)`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
|
||||
description: `Number of NPS passives (score 7-8)`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6 THEN 1 END)::numeric)
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
},
|
||||
|
||||
npsAverage: {
|
||||
averageScore: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps'` }],
|
||||
description: `Average NPS rating (0-10)`,
|
||||
},
|
||||
|
||||
csatCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CSAT responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
csatSatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4` }],
|
||||
description: `Number of satisfied CSAT responses (top-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatDissatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number BETWEEN 1 AND 2` }],
|
||||
description: `Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatNeutralCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number = 3` }],
|
||||
description: `Number of neutral CSAT responses (middle box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
ELSE ROUND(
|
||||
(
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4 THEN 1 END)::numeric
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `CSAT Score: % of answered CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale). NULL when there are no answered CSAT responses.`,
|
||||
},
|
||||
|
||||
csatAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat'` }],
|
||||
description: `Average CSAT rating (1-5)`,
|
||||
},
|
||||
|
||||
cesCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CES responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
cesAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces'` }],
|
||||
description: `Average CES rating (scale is 1-5 or 1-7 depending on the question)`,
|
||||
description: `Average NPS score`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -151,70 +77,22 @@ cube(`FeedbackRecords`, {
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
fieldLabel: {
|
||||
sql: `field_label`,
|
||||
type: `string`,
|
||||
description: `Human-readable label of the question/field (e.g., "How satisfied are you with support?")`,
|
||||
},
|
||||
|
||||
fieldGroupLabel: {
|
||||
sql: `field_group_label`,
|
||||
type: `string`,
|
||||
description: `Label of the parent composite question for matrix/ranking rows`,
|
||||
},
|
||||
|
||||
language: {
|
||||
sql: `language`,
|
||||
type: `string`,
|
||||
description: `Response language code (e.g., "en", "de"). NULL when language is "default".`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
createdAt: {
|
||||
sql: `created_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was created in Hub`,
|
||||
},
|
||||
|
||||
updatedAt: {
|
||||
sql: `updated_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was last updated in Hub`,
|
||||
},
|
||||
|
||||
valueNumber: {
|
||||
npsValue: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, generic number). Pair with a fieldType filter to keep scales consistent.`,
|
||||
},
|
||||
|
||||
valueText: {
|
||||
sql: `value_text`,
|
||||
type: `string`,
|
||||
description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`,
|
||||
},
|
||||
|
||||
valueBoolean: {
|
||||
sql: `value_boolean`,
|
||||
type: `boolean`,
|
||||
description: `Boolean answer value (yes/no questions). Pair with a fieldType filter.`,
|
||||
},
|
||||
|
||||
valueDate: {
|
||||
sql: `value_date`,
|
||||
type: `time`,
|
||||
description: `Date answer value (e.g., "preferred meeting date"). Pair with a fieldType filter.`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `submission_id`,
|
||||
sql: `response_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
},
|
||||
|
||||
userId: {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ That's it! After running the command and providing the required information, vis
|
||||
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and the bundled Cube service. Hub and Cube share the same database as Formbricks by default and both start as part of the baseline `docker compose up`.
|
||||
|
||||
- **Migrations**: A `hub-migrate` service runs Hub's database migrations (goose + river) before the Hub API starts. It runs on every `docker compose up` and is idempotent.
|
||||
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` and `CUBEJS_API_SECRET` (both required). Run `docker compose config >/dev/null` after creating `.env`; it fails if either value is missing or empty. `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the compose network. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
|
||||
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` and `CUBEJS_API_SECRET` (both required). `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the compose network. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
|
||||
- **Development** (`docker-compose.dev.yml`): Hub uses a dedicated local `hub` database and `HUB_API_KEY` defaults to `dev-api-key`. The dev stack starts `hub` plus `hub-worker`; set `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, and any provider credentials in the repo root `.env` to enable Hub embeddings locally. See the [Hub embeddings environment reference](https://hub.formbricks.com/reference/environment-variables/#embeddings) for provider-specific values. Cube starts with the dev stack, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub`, `hub-worker`, and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
|
||||
|
||||
In development, Hub is exposed locally on port **8080** and Cube on **4000** (with the Cube playground on **4001**). In production Docker Compose, both stay internal to the compose network at `http://hub:8080` and `http://cube:4000`.
|
||||
|
||||
@@ -40,7 +40,7 @@ x-environment: &environment
|
||||
|
||||
# Cube semantic-layer API used by Formbricks analytics. Required.
|
||||
CUBEJS_API_URL: ${CUBEJS_API_URL:-http://cube:4000}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
|
||||
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
|
||||
CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube}
|
||||
|
||||
@@ -312,7 +312,7 @@ services:
|
||||
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
|
||||
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
|
||||
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube}
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
|
||||
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
|
||||
CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube}
|
||||
CUBEJS_DEFAULT_API_SCOPES: meta,data
|
||||
|
||||
@@ -18,8 +18,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
<Info>
|
||||
Starting with Formbricks v5, the production Docker Compose stack includes Formbricks Hub and Cube as part of
|
||||
the baseline. Generate `HUB_API_KEY` and `CUBEJS_API_SECRET` during setup, keep `HUB_API_URL` at its
|
||||
internal default unless Hub runs elsewhere, and use the [migration
|
||||
guide](/self-hosting/advanced/migration#v5) when upgrading an existing 4.x instance.
|
||||
internal default unless Hub runs elsewhere, and use the [migration guide](/self-hosting/advanced/migration#v5)
|
||||
when upgrading an existing 4.x instance.
|
||||
</Info>
|
||||
|
||||
## Start
|
||||
@@ -56,16 +56,6 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
EOF
|
||||
```
|
||||
|
||||
1. **Validate the Docker Compose Configuration**
|
||||
|
||||
Confirm Docker Compose can resolve the required Hub and Cube variables before starting the stack:
|
||||
|
||||
```bash
|
||||
docker compose config >/dev/null
|
||||
```
|
||||
|
||||
This command fails if `HUB_API_KEY` or `CUBEJS_API_SECRET` is missing or empty in `.env`.
|
||||
|
||||
1. **Generate NextAuth Secret**
|
||||
|
||||
You need a NextAuth secret for session signing and encryption. Run one of the commands below based on your operating system:
|
||||
@@ -132,8 +122,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
|
||||
<Info>
|
||||
The bundled production stack already sets <code>HUB_API_URL</code> to <code>http://hub:8080</code>. Only
|
||||
change that value if your Formbricks app needs to reach Hub at a different address. If your deployment
|
||||
also resolves Compose variables from a shell environment or <code>.env</code> file, keep the same
|
||||
change that value if your Formbricks app needs to reach Hub at a different address. If your deployment also
|
||||
resolves Compose variables from a shell environment or <code>.env</code> file, keep the same
|
||||
<code>HUB_API_KEY</code> available there as well.
|
||||
</Info>
|
||||
|
||||
@@ -153,8 +143,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
Once the setup is running, open [**http://localhost:3000**](http://localhost:3000) in your browser to access Formbricks. The first time you visit, you'll see a setup wizard. Follow the steps to create your first user and start using Formbricks.
|
||||
|
||||
<Note>
|
||||
The bundled Docker stack keeps Formbricks Hub and Cube internal to the compose network. The app reaches them
|
||||
through `http://hub:8080` and `http://cube:4000`.
|
||||
The bundled Docker stack keeps Formbricks Hub and Cube internal to the compose network. The app reaches
|
||||
them through `http://hub:8080` and `http://cube:4000`.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
|
||||
+13
-6
@@ -1,13 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { ZActionClass } from "./action-classes";
|
||||
import { ZId } from "./common";
|
||||
import { ZJsWorkspaceStateSegment } from "./segment";
|
||||
import { ZUploadFileConfig } from "./storage";
|
||||
import { ZSurveyBase, surveyRefinement } from "./surveys/types";
|
||||
import { ZWorkspace } from "./workspace";
|
||||
|
||||
export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
// name intentionally omitted — internal label, not needed by SDK
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
@@ -19,7 +20,7 @@ export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
|
||||
autoClose: true,
|
||||
styling: true,
|
||||
status: true,
|
||||
segment: true,
|
||||
// segment intentionally omitted from pick — replaced with minimal shape below
|
||||
recontactDays: true,
|
||||
displayLimit: true,
|
||||
displayOption: true,
|
||||
@@ -31,9 +32,16 @@ export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
recaptcha: true,
|
||||
}).superRefine((survey, ctx) => {
|
||||
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
|
||||
});
|
||||
})
|
||||
.extend({
|
||||
// Only expose what the SDK needs: segment ID for membership check + whether any filters exist.
|
||||
// Full filter logic (titles, descriptions, conditions) is evaluated server-side and must not
|
||||
// be sent to the browser to avoid leaking sensitive targeting data.
|
||||
segment: ZJsWorkspaceStateSegment.nullable(),
|
||||
})
|
||||
.superRefine((survey, ctx) => {
|
||||
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
|
||||
});
|
||||
|
||||
export type TJsWorkspaceStateSurvey = z.infer<typeof ZJsWorkspaceStateSurvey>;
|
||||
|
||||
@@ -48,7 +56,6 @@ export const ZJsWorkspaceStateActionClass = ZActionClass.pick({
|
||||
export type TJsWorkspaceStateActionClass = z.infer<typeof ZJsWorkspaceStateActionClass>;
|
||||
|
||||
export const ZJsWorkspaceStateWorkspaceSetting = ZWorkspace.pick({
|
||||
id: true,
|
||||
recontactDays: true,
|
||||
clickOutsideClose: true,
|
||||
overlay: true,
|
||||
|
||||
Reference in New Issue
Block a user