Compare commits

..

4 Commits

Author SHA1 Message Date
Javi Aguilar cb82ca7ff4 fix preact not supporting important in styles 2026-05-21 17:29:16 +02:00
Javi Aguilar 6ef5b48590 declare cva config outside 2026-05-21 17:09:13 +02:00
Javi Aguilar fb4bf7ca6d fix: back button not readable in dark backgrounds - fixes #7922 2026-05-21 16:28:31 +02:00
Javi Aguilar 11b4d34b79 refactor: allow arrays and falsy values in cn util 2026-05-21 16:27:51 +02:00
235 changed files with 1200 additions and 3575 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: v3.15.4
version: latest
- name: Log in to GitHub Container Registry
env:
@@ -66,6 +66,11 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -57,6 +57,7 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
@@ -65,6 +66,7 @@ describe("organization AI settings actions", () => {
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
@@ -112,15 +114,18 @@ describe("organization AI settings actions", () => {
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
@@ -189,6 +194,7 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
@@ -71,11 +71,12 @@ export const updateOrganizationNameAction = authenticatedActionClient
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled"
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
@@ -89,10 +90,16 @@ const resolveOrganizationAISettings = ({
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
};
};
@@ -50,18 +50,29 @@ export const AISettingsToggle = ({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (checked: boolean) => {
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField("isAISmartToolsEnabled");
setLoadingField(field);
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data: { isAISmartToolsEnabled: checked },
data,
});
if (response?.data) {
@@ -111,7 +122,7 @@ export const AISettingsToggle = ({
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={handleToggle}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("workspace.settings.general.ai_smart_tools_enabled")}
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
@@ -119,6 +130,16 @@ export const AISettingsToggle = ({
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("workspace.settings.general.ai_data_analysis_enabled")}
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
@@ -9,6 +9,7 @@ import {
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
@@ -37,11 +38,14 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
const user = session?.user?.id ? await getUser(session.user.id) : null;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
]);
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -4,6 +4,7 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
+2 -11
View File
@@ -313,18 +313,9 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 404 notFound for ResourceNotFoundError", async () => {
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
+5 -4
View File
@@ -29,10 +29,11 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -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,
@@ -1,12 +1,7 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganization } from "@/lib/organization/service";
@@ -160,16 +155,6 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -16,7 +11,6 @@ import {
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -110,16 +104,6 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contact?.id ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
@@ -147,13 +131,6 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -6,7 +6,6 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -57,17 +56,11 @@ export const POST = withV1ApiWrapper({
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
responseInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
@@ -218,7 +211,7 @@ export const POST = withV1ApiWrapper({
response: responseData,
});
if (responseInputData.finished) {
if (responseInput.finished) {
await sendToPipeline({
event: "responseFinished",
workspaceId,
@@ -3,7 +3,6 @@ import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classe
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -85,14 +84,8 @@ export const PUT = withV1ApiWrapper({
let actionClassUpdate;
try {
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
actionClassUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -46,14 +45,8 @@ export const POST = withV1ApiWrapper({
try {
let actionClassInput;
try {
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
actionClassInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { TResponseData, ZResponseUpdateInput } from "@formbricks/types/responses";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -13,11 +12,6 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
type TUncheckedResponseUpdate = Record<string, unknown> & {
data: TResponseData;
language?: string;
};
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiV1Authentication | undefined,
@@ -126,16 +120,10 @@ export const PUT = withV1ApiWrapper({
auditLog.oldObject = result.response;
}
let responseUpdate: TUncheckedResponseUpdate;
let responseUpdate;
try {
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
responseUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -92,14 +91,8 @@ export const POST = withV1ApiWrapper({
try {
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
jsonInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -20,14 +19,8 @@ export const POST = withV1ApiWrapper({
let storageInput;
try {
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
storageInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -9,7 +9,6 @@ import {
addLegacyProjectOverwrites,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -23,12 +22,6 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
type TSurveyUpdateBody = Record<string, unknown> & {
blocks?: Parameters<typeof validateSurveyInput>[0]["blocks"];
endings?: Parameters<typeof transformQuestionsToBlocks>[1];
questions?: Parameters<typeof transformQuestionsToBlocks>[0];
};
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
@@ -171,16 +164,10 @@ export const PUT = withV1ApiWrapper({
};
}
let surveyUpdate: TSurveyUpdateBody;
let surveyUpdate;
try {
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
surveyUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -201,7 +188,7 @@ export const PUT = withV1ApiWrapper({
if (hasQuestions) {
surveyUpdate.blocks = transformQuestionsToBlocks(
surveyUpdate.questions ?? [],
surveyUpdate.questions,
surveyUpdate.endings || result.survey.endings
);
surveyUpdate.questions = [];
@@ -221,11 +208,7 @@ export const PUT = withV1ApiWrapper({
};
}
const featureCheckResult = await checkFeaturePermissions(
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
organization,
result.survey
);
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -51,6 +51,7 @@ const mockOrganization: TOrganization = {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithWorkspaceId["followUps"][number] = {
@@ -8,7 +8,6 @@ import {
addLegacyProjectOverwritesToList,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -85,14 +84,8 @@ export const POST = withV1ApiWrapper({
try {
let surveyInput;
try {
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
surveyInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
+2 -9
View File
@@ -2,7 +2,6 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -41,14 +40,8 @@ export const POST = withV1ApiWrapper({
let webhookInput;
try {
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
webhookInput = await req.json();
} catch {
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -195,19 +190,7 @@ describe("createResponse V2", () => {
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["someOtherField"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
@@ -216,7 +199,7 @@ describe("createResponse V2", () => {
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(InvalidInputError);
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -17,7 +12,6 @@ import {
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -105,16 +99,6 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contactId ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -138,13 +122,6 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -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({
+2 -11
View File
@@ -4,7 +4,6 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -17,7 +16,6 @@ import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemPayloadTooLarge,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
@@ -172,15 +170,8 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
let bodyData: unknown;
try {
bodyData = await parseJsonBodyWithLimit(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
ok: false,
response: problemPayloadTooLarge(requestId, error.message, instance),
};
}
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
-11
View File
@@ -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",
-76
View File
@@ -1,76 +0,0 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
RequestBodyTooLargeError,
parseJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./request-body";
const createStreamingRequest = (chunks: string[]): Request =>
new Request("http://localhost/api/test", {
method: "POST",
body: new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
describe("request body parsing", () => {
test("rejects a request when content-length exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
},
body: "{}",
});
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
name: "RequestBodyTooLargeError",
});
});
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
});
test("allows a body exactly at the body limit", async () => {
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
const request = new Request("http://localhost/api/test", {
method: "POST",
body: rawBody,
});
const body = await readRequestBodyWithLimit(request);
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
expect(body).toBe(rawBody);
});
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
body: "{invalid-json",
});
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
});
test("returns an empty string for requests without a body", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
});
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
});
});
-90
View File
@@ -1,90 +0,0 @@
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
export class RequestBodyTooLargeError extends Error {
readonly actualBytes: number | null;
readonly limitBytes: number;
constructor(limitBytes: number, actualBytes: number | null = null) {
super(`Request body must not exceed ${limitBytes} bytes`);
this.name = "RequestBodyTooLargeError";
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}
const textDecoder = new TextDecoder();
const getContentLength = (headers: Headers): number | null => {
const contentLength = headers.get("content-length");
if (!contentLength) {
return null;
}
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength < 0) {
return null;
}
return parsedContentLength;
};
const assertBodySize = (actualBytes: number, limitBytes: number): void => {
if (actualBytes > limitBytes) {
throw new RequestBodyTooLargeError(limitBytes, actualBytes);
}
};
export const readRequestBodyWithLimit = async (
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<string> => {
const contentLength = getContentLength(request.headers);
if (contentLength !== null) {
assertBodySize(contentLength, limitBytes);
}
if (!request.body) {
return "";
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > limitBytes) {
await reader.cancel().catch(() => undefined);
throw new RequestBodyTooLargeError(limitBytes, receivedBytes);
}
chunks.push(value);
}
if (chunks.length === 0) {
return "";
}
if (chunks.length === 1) {
return textDecoder.decode(chunks[0]);
}
const body = new Uint8Array(receivedBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return textDecoder.decode(body);
};
export const parseJsonBodyWithLimit = async <TJson = unknown>(
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<TJson> => JSON.parse(await readRequestBodyWithLimit(request, limitBytes)) as TJson;
+1 -27
View File
@@ -17,8 +17,7 @@ interface ApiErrorResponse {
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict"
| "payload_too_large";
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -81,30 +80,6 @@ const badRequestResponse = (
);
};
const payloadTooLargeResponse = (
message: string = "Payload Too Large",
details: ApiErrorResponse["details"] = {},
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "payload_too_large",
message,
details,
},
{
status: 413,
headers,
}
);
};
const methodNotAllowedResponse = (
res: CustomNextApiResponse,
allowedMethods: string[],
@@ -319,7 +294,6 @@ export const responses = {
unauthorizedResponse,
notFoundResponse,
successResponse,
payloadTooLargeResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
+65 -3
View File
@@ -3,6 +3,7 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
@@ -12,6 +13,7 @@ const mocks = vi.hoisted(() => ({
generateText: vi.fn(),
isAiConfigured: vi.fn(),
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
loggerError: vi.fn(),
}));
@@ -61,6 +63,7 @@ vi.mock("@/lib/organization/service", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
@@ -72,8 +75,10 @@ describe("AI organization service", () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
});
test("returns the instance AI status and organization settings", async () => {
@@ -84,7 +89,9 @@ describe("AI organization service", () => {
expect(result).toMatchObject({
organizationId: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
isAISmartToolsEntitled: true,
isAIDataAnalysisEntitled: true,
isInstanceConfigured: true,
});
});
@@ -98,22 +105,29 @@ describe("AI organization service", () => {
test("fails closed when the organization is not entitled to AI", async () => {
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
});
test("fails closed when the requested AI capability is disabled", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: "org_1",
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: true,
});
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
});
test("fails closed when the instance AI configuration is incomplete", async () => {
mocks.isAiConfigured.mockReturnValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
});
test("generates organization AI text with the configured package abstraction", async () => {
@@ -122,6 +136,7 @@ describe("AI organization service", () => {
const result = await generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
});
@@ -145,12 +160,14 @@ describe("AI organization service", () => {
await expect(
generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
})
).rejects.toThrow(modelError);
expect(mocks.loggerError).toHaveBeenCalledWith(
{
organizationId: "org_1",
capability: "smartTools",
isInstanceConfigured: true,
errorCode: undefined,
err: modelError,
@@ -159,11 +176,46 @@ describe("AI organization service", () => {
);
});
describe("getAIDataAnalysisUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
test("returns undefined when all checks pass", () => {
expect(getAIDataAnalysisUnavailableReason(baseConfig)).toBeUndefined();
});
test("returns not_in_plan when not entitled", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEntitled: false })).toBe(
"not_in_plan"
);
});
test("returns not_enabled when disabled at org level", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEnabled: false })).toBe(
"not_enabled"
);
});
test("returns instance_not_configured when instance AI is missing", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
"instance_not_configured"
);
});
});
describe("getAISmartToolsUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
@@ -188,5 +240,15 @@ describe("AI organization service", () => {
"instance_not_configured"
);
});
test("ignores data-analysis flags (smart tools is independent of data analysis state)", () => {
expect(
getAISmartToolsUnavailableReason({
...baseConfig,
isAIDataAnalysisEntitled: false,
isAIDataAnalysisEnabled: false,
})
).toBeUndefined();
});
});
});
+33 -6
View File
@@ -4,11 +4,12 @@ import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export const AI_ERROR_CODES = {
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
} as const;
@@ -17,7 +18,9 @@ export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
isAISmartToolsEntitled: boolean;
isAIDataAnalysisEntitled: boolean;
isInstanceConfigured: boolean;
}
@@ -30,18 +33,32 @@ export const getOrganizationAIConfig = async (organizationId: string): Promise<T
throw new ResourceNotFoundError("Organization", organizationId);
}
const isAISmartToolsEntitled = await getIsAISmartToolsEnabled(organizationId);
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
getIsAISmartToolsEnabled(organizationId),
getIsAIDataAnalysisEnabled(organizationId),
]);
return {
organizationId,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
isAISmartToolsEntitled,
isAIDataAnalysisEntitled,
isInstanceConfigured: isInstanceAIConfigured(),
};
};
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export const getAIDataAnalysisUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
if (!aiConfig.isAIDataAnalysisEntitled) return "not_in_plan";
if (!aiConfig.isAIDataAnalysisEnabled) return "not_enabled";
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
return undefined;
};
export const getAISmartToolsUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
@@ -52,18 +69,25 @@ export const getAISmartToolsUnavailableReason = (
};
export const assertOrganizationAIConfigured = async (
organizationId: string
organizationId: string,
capability: "smartTools" | "dataAnalysis"
): Promise<TOrganizationAIConfig> => {
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!aiConfig.isAISmartToolsEntitled) {
if (!isCapabilityEntitled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
}
if (!aiConfig.isAISmartToolsEnabled) {
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
}
@@ -73,13 +97,15 @@ export const assertOrganizationAIConfigured = async (
type TGenerateOrganizationAITextInput = {
organizationId: string;
capability: "smartTools" | "dataAnalysis";
} & Parameters<typeof generateText>[0];
export const generateOrganizationAIText = async ({
organizationId,
capability,
...options
}: TGenerateOrganizationAITextInput): Promise<Awaited<ReturnType<typeof generateText>>> => {
const aiConfig = await assertOrganizationAIConfigured(organizationId);
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
try {
return await generateText(options, env);
@@ -87,6 +113,7 @@ export const generateOrganizationAIText = async ({
logger.error(
{
organizationId,
capability,
isInstanceConfigured: aiConfig.isInstanceConfigured,
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
err: error,
+1 -53
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
export const selectDisplay = {
@@ -146,58 +146,6 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
}
);
export const getDisplayForResponseValidation = async (
displayId: string,
tx?: Prisma.TransactionClient
): Promise<{
surveyId: string;
workspaceId: string;
responseId: string | null;
contactId: string | null;
} | null> => {
validateInputs([displayId, ZId]);
const client = tx ?? prisma;
try {
const display = await client.display.findUnique({
where: { id: displayId },
select: {
surveyId: true,
contactId: true,
response: { select: { id: true } },
survey: { select: { workspaceId: true } },
},
});
if (!display) return null;
return {
surveyId: display.surveyId,
workspaceId: display.survey.workspaceId,
responseId: display.response?.id ?? null,
contactId: display.contactId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
throw error;
}
};
export const assertDisplayOwnership = async (
displayId: string,
workspaceId: string,
surveyId: string,
contactId: string | null,
tx?: Prisma.TransactionClient
): Promise<void> => {
const display = await getDisplayForResponseValidation(displayId, tx);
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
if (display.workspaceId !== workspaceId)
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
if (display.surveyId !== surveyId)
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
if (display.contactId !== null && display.contactId !== contactId)
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
};
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
@@ -3,18 +3,14 @@ import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
assertDisplayOwnership,
getDisplayCountBySurveyId,
getDisplayForResponseValidation,
getDisplaysByContactId,
getDisplaysBySurveyIdWithContact,
} from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
const mockResponseId = "clqnfg59i000208i426pb4wcv";
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
const mockDisplaysForContact = [
@@ -294,96 +290,3 @@ describe("getDisplaysBySurveyIdWithContact", () => {
});
});
});
const mockDisplayRecord = {
surveyId: mockSurveyId,
contactId: null as string | null,
response: null as { id: string } | null,
survey: { workspaceId: mockWorkspaceId },
};
describe("getDisplayForResponseValidation", () => {
test("returns null when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toBeNull();
});
test("returns mapped shape when display is found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
response: { id: mockResponseId },
} as any);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toEqual({
surveyId: mockSurveyId,
workspaceId: mockWorkspaceId,
responseId: mockResponseId,
contactId: mockContactId,
});
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
vi.mocked(prisma.display.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
});
});
describe("assertDisplayOwnership", () => {
test("throws InvalidInputError when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when workspaceId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when surveyId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when display is already linked to a response", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
response: { id: mockResponseId },
} as any);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when contactId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: "contact-a",
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
).rejects.toThrow(InvalidInputError);
});
test("resolves without error when all ownership checks pass", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
).resolves.toBeUndefined();
});
});
+1
View File
@@ -38,6 +38,7 @@ describe("auth", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+5 -26
View File
@@ -46,13 +46,6 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/hub/service", () => ({
deleteHubTenantData: vi.fn().mockResolvedValue({
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
error: null,
}),
}));
describe("Organization Service", () => {
beforeEach(() => {
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
@@ -80,6 +73,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -132,6 +126,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -184,6 +179,7 @@ describe("Organization Service", () => {
updatedAt: new Date(),
billing: expectedBilling,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -243,6 +239,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
workspaces: [
@@ -284,6 +281,7 @@ describe("Organization Service", () => {
usageCycleAnchor: expect.any(Date),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
@@ -357,7 +355,6 @@ describe("Organization Service", () => {
billing: { stripeCustomerId: "cus_123" },
memberships: [],
workspaces: [],
feedbackDirectories: [],
} as any);
await deleteOrganization("org1");
@@ -366,23 +363,5 @@ describe("Organization Service", () => {
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
}
});
test("should purge Hub-owned data for each feedback directory", async () => {
const { deleteHubTenantData } = await import("@/modules/hub/service");
vi.mocked(prisma.organization.delete).mockResolvedValue({
id: "org1",
name: "Test Org",
billing: null,
memberships: [],
workspaces: [],
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
} as any);
await deleteOrganization("org1");
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
});
+2 -13
View File
@@ -19,7 +19,6 @@ import { updateUser } from "@/lib/user/service";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getWorkspaces } from "@/lib/workspace/service";
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
import { deleteHubTenantData } from "@/modules/hub/service";
import { validateInputs } from "../utils/validate";
export const select = {
@@ -36,6 +35,7 @@ export const select = {
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -74,6 +74,7 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
@@ -293,11 +294,6 @@ export const deleteOrganization = async (organizationId: string) => {
id: true,
},
},
feedbackDirectories: {
select: {
id: true,
},
},
},
});
@@ -305,13 +301,6 @@ export const deleteOrganization = async (organizationId: string) => {
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
await cleanupStripeCustomer(stripeCustomerId);
}
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) for each
// directory tenant. Failures are logged inside the gateway and do not roll back the
// local delete.
for (const directory of deletedOrganization.feedbackDirectories) {
await deleteHubTenantData(directory.id);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
-31
View File
@@ -1,7 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -325,35 +324,5 @@ describe("updateResponse", () => {
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
});
});
-8
View File
@@ -3,7 +3,6 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -570,13 +569,6 @@ export const updateResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Response", responseId);
}
throw new DatabaseError(error.message);
}
@@ -228,6 +228,7 @@ export const mockOrganizationOutput: TOrganization = {
createdAt: currentDate,
updatedAt: currentDate,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
limits: {
+2
View File
@@ -67,6 +67,7 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
{
id: "org2",
@@ -84,6 +85,7 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
@@ -18,7 +18,6 @@ import {
ValidationError,
isExpectedError,
} from "@formbricks/types/errors";
import { RequestBodyTooLargeError } from "@/app/lib/api/request-body";
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
@@ -79,7 +78,6 @@ describe("isExpectedError (shared helper)", () => {
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
"RequestBodyTooLargeError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -99,7 +97,6 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
{ ErrorClass: RequestBodyTooLargeError, args: [2 * 1024 * 1024] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
},
"general": {
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
"ai_data_analysis_enabled_description": "KI nutzen, um mehr aus deinen Daten herauszuholen richte Dashboards, Diagramme, Berichte und mehr ein. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks KI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte deine:n Administrator:in, AI_PROVIDER, AI_MODEL und die passenden Provider-Zugangsdaten zu setzen, bevor du KI-Funktionen aktivierst.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "„Umfrage geschlossen“-Nachricht anpassen",
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_theme_in_look_and_feel_settings": "Passe das Theme in den <lookFeelLink>Look & Feel</lookFeelLink> Einstellungen an.",
"ai_data_analysis_disabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_features_not_enabled": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Workspaces being granted access"
},
"general": {
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Manage AI-powered features for this organization.",
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_theme_in_look_and_feel_settings": "Adjust the theme in the <lookFeelLink>Look & Feel</lookFeelLink> Settings.",
"ai_data_analysis_disabled": "AI data analysis is disabled for this organization.",
"ai_features_not_enabled": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
"ai_data_analysis_disabled": "El análisis de datos con IA está deshabilitado para esta organización.",
"ai_features_not_enabled": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Espaces de travail en cours d'ajout"
},
"general": {
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
"ai_enabled": "IA Formbricks",
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
"ai_instance_not_configured": "L'IA est configurée au niveau de l'instance via des variables d'environnement. Demandez à votre administrateur de définir AI_PROVIDER, les identifiants du fournisseur et la liste de modèles correspondante avant d'activer les fonctionnalités d'IA.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
"adjust_theme_in_look_and_feel_settings": "Ajuste le thème dans les paramètres <lookFeelLink>Apparence et ressenti</lookFeelLink>.",
"ai_data_analysis_disabled": "L'analyse de données par IA est désactivée pour cette organisation.",
"ai_features_not_enabled": "Les fonctionnalités IA ne sont pas activées pour cette organisation.",
"ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.",
"ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
},
"general": {
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
"ai_data_analysis_disabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
"ai_features_not_enabled": "Az AI funkciók nincsenek engedélyezve ezen szervezet számára.",
"ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.",
"ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "アクセス権が付与されるワークスペース"
},
"general": {
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "この組織のAI機能を管理します。",
"ai_instance_not_configured": "AI は環境変数を使ってインスタンスレベルで設定されます。AI 機能を有効にする前に、管理者に AI_PROVIDER、このプロバイダーの認証情報、および対応するモデル一覧を設定してもらってください。",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "「フォームはクローズしました」メッセージを調整",
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
"ai_data_analysis_disabled": "この組織ではAIデータ分析が無効になっています。",
"ai_features_not_enabled": "この組織ではAI機能が有効になっていません。",
"ai_instance_not_configured": "AIが設定されていません。管理者にお問い合わせください。",
"ai_smart_tools_disabled": "この組織ではAIスマートツールが無効になっています。",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Werkruimtes die toegang krijgen"
},
"general": {
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
"ai_instance_not_configured": "AI wordt op instantieniveau geconfigureerd via omgevingsvariabelen. Vraag je beheerder om AI_PROVIDER, de inloggegevens voor die provider en de bijbehorende modellenlijst in te stellen voordat AI-functies worden ingeschakeld.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Pas het bericht 'Enquête gesloten' aan",
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
"ai_data_analysis_disabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
"ai_features_not_enabled": "AI-functies zijn niet ingeschakeld voor deze organisatie.",
"ai_instance_not_configured": "AI is niet geconfigureerd. Neem contact op met je beheerder.",
"ai_smart_tools_disabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Workspaces recebendo acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
"ai_instance_not_configured": "A IA é configurada no nível da instância por meio de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse provedor e a lista de modelos correspondente antes de habilitar os recursos de IA.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Ajustar mensagem 'Pesquisa Encerrada''",
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
"adjust_theme_in_look_and_feel_settings": "Ajuste o tema nas configurações de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desabilitada para esta organização.",
"ai_features_not_enabled": "Os recursos de IA não estão habilitados para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Entre em contato com seu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Workspaces a receber acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
"ai_enabled": "IA da Formbricks",
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
"ai_instance_not_configured": "A IA é configurada ao nível da instância através de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse fornecedor e a lista de modelos correspondente antes de ativar as funcionalidades de IA.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Ajustar mensagem de 'Inquérito Fechado'",
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
"adjust_theme_in_look_and_feel_settings": "Ajusta o tema nas definições de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desativada para esta organização.",
"ai_features_not_enabled": "As funcionalidades de IA não estão ativadas para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Contacta o teu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
},
"general": {
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
"ai_instance_not_configured": "AI este configurată la nivel de instanță prin variabile de mediu. Cere administratorului să configureze AI_PROVIDER, credențialele acelui furnizor și lista de modele corespunzătoare înainte de a activa funcționalitățile AI.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'",
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
"adjust_theme_in_look_and_feel_settings": "Ajustează tema în setările <lookFeelLink>Aspect și Experiență</lookFeelLink>.",
"ai_data_analysis_disabled": "Analiza de date AI este dezactivată pentru această organizație.",
"ai_features_not_enabled": "Funcțiile AI nu sunt activate pentru această organizație.",
"ai_instance_not_configured": "AI nu este configurat. Contactează administratorul.",
"ai_smart_tools_disabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
},
"general": {
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
"ai_instance_not_configured": "ИИ настраивается на уровне инстанса через переменные окружения. Попросите администратора настроить AI_PROVIDER, учетные данные этого провайдера и соответствующий список моделей перед включением функций ИИ.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Изменить сообщение «Опрос закрыт»",
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
"ai_data_analysis_disabled": "Анализ данных с помощью ИИ отключён для этой организации.",
"ai_features_not_enabled": "Функции ИИ не включены для этой организации.",
"ai_instance_not_configured": "ИИ не настроен. Свяжись с администратором.",
"ai_smart_tools_disabled": "Умные инструменты ИИ отключены для этой организации.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
},
"general": {
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
"ai_instance_not_configured": "AI konfigureras på instansnivå via miljövariabler. Be din administratör att ange AI_PROVIDER, autentiseringsuppgifterna för den leverantören och den tillhörande modellistan innan AI-funktioner aktiveras.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "Justera meddelande för 'Enkät stängd'",
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
"ai_data_analysis_disabled": "AI-dataanalys är inaktiverad för den här organisationen.",
"ai_features_not_enabled": "AI-funktioner är inte aktiverade för den här organisationen.",
"ai_instance_not_configured": "AI är inte konfigurerad. Kontakta din administratör.",
"ai_smart_tools_disabled": "AI smarta verktyg är inaktiverade för den här organisationen.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "Erişim verilen çalışma alanları"
},
"general": {
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
"ai_data_analysis_enabled_description": "Verilerinden daha fazlasını elde etmek, kontrol panelleri, grafikler, raporlar ve daha fazlasını kurmak için yapay zeka. Deneyim verilerine dokunur.",
"ai_enabled": "Formbricks Yapay Zeka",
"ai_enabled_description": "Bu organizasyon için yapay zeka destekli özellikleri yönet.",
"ai_instance_not_configured": "Yapay zeka, ortam değişkenleri aracılığıyla instance seviyesinde yapılandırılır. Yapay zeka özelliklerini etkinleştirmeden önce yöneticinden AI_PROVIDER, AI_MODEL ve eşleşen sağlayıcı kimlik bilgilerini ayarlamasını iste.",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "\"Anket Kapatıldı\" mesajını düzenle",
"adjust_survey_closed_message_description": "Anket kapalıyken ziyaretçilerin gördüğü mesajı değiştir.",
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
"ai_data_analysis_disabled": "Bu organizasyon için yapay zeka veri analizi devre dışı.",
"ai_features_not_enabled": "Bu organizasyon için yapay zeka özellikleri etkinleştirilmemiş.",
"ai_instance_not_configured": "Yapay zeka yapılandırılmamış. Yöneticinle iletişime geç.",
"ai_smart_tools_disabled": "Bu organizasyon için yapay zeka akıllı araçları devre dışı.",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "将被授权访问的工作区"
},
"general": {
"ai_data_analysis_enabled": "数据增强与分析(AI",
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
"ai_instance_not_configured": "AI 通过环境变量在实例级别进行配置。启用 AI 功能前,请让管理员设置 AI_PROVIDER、该提供商的凭据以及对应的模型列表。",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "调整 \"调查 关闭\" 消息",
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
"ai_data_analysis_disabled": "此组织已禁用 AI 数据分析。",
"ai_features_not_enabled": "此组织未启用 AI 功能。",
"ai_instance_not_configured": "AI 未配置。请联系您的管理员。",
"ai_smart_tools_disabled": "此组织已禁用 AI 智能工具。",
+3
View File
@@ -2612,6 +2612,8 @@
"workspaces_being_added": "正在授予存取權限的工作區"
},
"general": {
"ai_data_analysis_enabled": "資料增強與分析(AI",
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理此組織的 AI 功能。",
"ai_instance_not_configured": "AI 會透過環境變數在實例層級進行設定。啟用 AI 功能前,請管理員設定 AI_PROVIDER、該供應商的憑證,以及對應的模型清單。",
@@ -2818,6 +2820,7 @@
"adjust_survey_closed_message": "調整「問卷已關閉」訊息",
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外觀與感覺</lookFeelLink>設定中調整主題。",
"ai_data_analysis_disabled": "此組織已停用 AI 資料分析。",
"ai_features_not_enabled": "此組織未啟用 AI 功能。",
"ai_instance_not_configured": "AI 未設定。請聯絡您的管理員。",
"ai_smart_tools_disabled": "此組織已停用 AI 智慧工具。",
+2 -15
View File
@@ -1,7 +1,6 @@
import { ZodRawShape, z } from "zod";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
@@ -74,22 +73,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
let bodyData: Record<string, unknown>;
let bodyData;
try {
bodyData = await parseJsonBodyWithLimit<Record<string, unknown>>(request);
bodyData = await request.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return handleApiError(request, {
type: "payload_too_large",
details: [
{
field: "body",
issue: error.message,
},
],
});
}
logger.error({ error, url: request.url }, "Error parsing JSON input");
return handleApiError(request, {
type: "bad_request",
@@ -1,7 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok } from "@formbricks/types/error-handlers";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { handleApiError } from "@/modules/api/v2/lib/utils";
@@ -165,42 +164,6 @@ describe("apiWrapper", () => {
});
});
test("should handle oversized JSON input in request body", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: "{}",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 413 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "payload_too_large",
details: [
{
field: "body",
issue: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
],
});
});
test("should handle empty body when body schema is provided", async () => {
const request = new Request("http://localhost", {
method: "POST",
-30
View File
@@ -148,35 +148,6 @@ const conflictResponse = ({
);
};
const payloadTooLargeResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 413,
message: "Payload Too Large",
details,
},
},
{
status: 413,
headers,
}
);
};
const unprocessableEntityResponse = ({
details = [],
cors = false,
@@ -380,7 +351,6 @@ export const responses = {
forbiddenResponse,
notFoundResponse,
conflictResponse,
payloadTooLargeResponse,
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
@@ -85,18 +85,6 @@ describe("utils", () => {
expect(body.error.message).toBe("Conflict");
});
test('return payload too large response for "payload_too_large" error', async () => {
const details = [{ field: "body", issue: "Request body must not exceed 2097152 bytes" }];
const error: ApiErrorResponseV2 = { type: "payload_too_large", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(413);
const body = await response.json();
expect(body.error.code).toBe(413);
expect(body.error.message).toBe("Payload Too Large");
expect(body.error.details).toEqual(details);
});
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
const details = [{ field: "data", issue: "malformed" }];
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
-2
View File
@@ -28,8 +28,6 @@ export const handleApiError = (
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse({ details: err.details });
case "payload_too_large":
return responses.payloadTooLargeResponse({ details: err.details });
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":
+1 -7
View File
@@ -10,13 +10,7 @@ export type ApiErrorDetails = {
export type ApiErrorResponseV2 =
| {
type:
| "unauthorized"
| "forbidden"
| "conflict"
| "payload_too_large"
| "too_many_requests"
| "internal_server_error";
type: "unauthorized" | "forbidden" | "conflict" | "too_many_requests" | "internal_server_error";
details?: ApiErrorDetails;
}
| {
@@ -10,6 +10,7 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
SPAM_PROTECTION: "spam-protection",
CONTACTS: "contacts",
AI_SMART_TOOLS: "ai-smart-tools",
AI_DATA_ANALYSIS: "ai-data-analysis",
FEEDBACK_DIRECTORIES: "feedback-directories",
DASHBOARDS: "dashboards",
} as const;
@@ -81,7 +81,7 @@ export const translateSurveyFieldsAction = authenticatedActionClient
],
});
await assertOrganizationAIConfigured(organizationId);
await assertOrganizationAIConfigured(organizationId, "smartTools");
const translations = await translateFields({
organizationId,
@@ -40,6 +40,7 @@ Rules:
const result = await generateOrganizationAIText({
organizationId,
capability: "smartTools",
system: systemPrompt,
prompt: JSON.stringify(items),
});
@@ -363,7 +363,10 @@ export const generateAIChartAction = authenticatedActionClient
await checkDashboardsEnabled(organizationId);
await assertOrganizationAIConfigured(organizationId);
// Verify AI is entitled, enabled at org level, and configured at instance level.
// Uses "smartTools" (not "dataAnalysis") because chart generation only sends the
// Cube schema context and the user's prompt to the LLM — no response PII.
await assertOrganizationAIConfigured(organizationId, "smartTools");
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
+1 -6
View File
@@ -1,12 +1,11 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body";
import { webhookHandler } from "@/modules/ee/billing/api/lib/stripe-webhook";
export const POST = async (request: Request) => {
try {
const body = await readRequestBodyWithLimit(request);
const body = await request.text();
const requestHeaders = await headers(); // Corrected: headers() is async
const signature = requestHeaders.get("stripe-signature");
@@ -27,10 +26,6 @@ export const POST = async (request: Request) => {
return NextResponse.json(result.message || { received: true }, { status: 200 });
} catch (error: any) {
if (error instanceof RequestBodyTooLargeError) {
return NextResponse.json({ message: "Payload Too Large" }, { status: 413 });
}
logger.error(error, `Unhandled error in Stripe webhook POST handler: ${error.message}`);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
@@ -4,7 +4,6 @@ import { ZId } from "@formbricks/types/common";
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -28,11 +27,6 @@ const handleError = (err: unknown, url: string): { response: Response; error?: u
};
};
type TContactUserRequestBody = Record<string, unknown> & {
attributes?: Record<string, unknown>;
userId?: unknown;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -82,24 +76,7 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
let jsonInput: TContactUserRequestBody;
try {
jsonInput = await parseJsonBodyWithLimit<TContactUserRequestBody>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const jsonInput = await req.json();
// Basic input validation without Zod overhead
if (
@@ -114,13 +91,8 @@ export const POST = withV1ApiWrapper({
}
// Simple email validation if present (avoid Zod)
const attributes =
typeof jsonInput.attributes === "object" && jsonInput.attributes !== null
? jsonInput.attributes
: undefined;
if (attributes && Object.hasOwn(attributes, "email")) {
const email = attributes.email;
if (jsonInput.attributes?.email) {
const email = jsonInput.attributes.email;
if (typeof email !== "string" || !email.includes("@") || email.length < 3) {
return {
response: responses.badRequestResponse("Invalid email format", undefined, true),
@@ -128,7 +100,7 @@ export const POST = withV1ApiWrapper({
}
}
const userId = jsonInput.userId;
const { userId, attributes } = jsonInput;
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
@@ -1,6 +1,5 @@
import { logger } from "@formbricks/logger";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -150,14 +149,8 @@ export const PUT = withV1ApiWrapper({
let contactAttributeKeyUpdate;
try {
contactAttributeKeyUpdate = await parseJsonBodyWithLimit(req);
contactAttributeKeyUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -64,14 +63,8 @@ export const POST = withV1ApiWrapper({
let contactAttributeKeyInput;
try {
contactAttributeKeyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
contactAttributeKeyInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -148,6 +148,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -285,6 +286,7 @@ describe("License Core Logic", () => {
removeBranding: false,
contacts: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -308,6 +310,7 @@ describe("License Core Logic", () => {
removeBranding: false,
contacts: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -340,6 +343,7 @@ describe("License Core Logic", () => {
removeBranding: false,
contacts: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -533,6 +537,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -599,6 +604,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -656,6 +662,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -800,6 +807,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: true,
aiDataAnalysis: true,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -828,6 +836,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: true,
aiDataAnalysis: true,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -857,6 +866,7 @@ describe("License Core Logic", () => {
removeBranding: false,
contacts: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -930,6 +940,7 @@ describe("License Core Logic", () => {
removeBranding: true,
contacts: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -998,6 +1009,7 @@ describe("License Core Logic", () => {
removeBranding: true,
contacts: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1039,6 +1051,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -1167,6 +1180,7 @@ describe("License Core Logic", () => {
saml: true,
spamProtection: true,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -1290,6 +1304,7 @@ describe("License Core Logic", () => {
removeBranding: true,
contacts: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1345,6 +1360,7 @@ describe("License Core Logic", () => {
removeBranding: true,
contacts: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1400,6 +1416,7 @@ describe("License Core Logic", () => {
removeBranding: true,
contacts: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -83,6 +83,7 @@ const LicenseFeaturesSchema = z.object({
removeBranding: z.boolean(),
contacts: z.boolean(),
aiSmartTools: z.boolean(),
aiDataAnalysis: z.boolean(),
saml: z.boolean(),
spamProtection: z.boolean(),
auditLogs: z.boolean(),
@@ -152,6 +153,7 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
removeBranding: false,
contacts: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -9,6 +9,7 @@ import { getEnterpriseLicense, getLicenseFeatures } from "./license";
import {
getAccessControlPermission,
getBiggerUploadFileSizePermission,
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsAuditLogsEnabled,
getIsContactsEnabled,
@@ -59,6 +60,7 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
saml: false,
spamProtection: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: false,
accessControl: false,
quotas: false,
@@ -214,26 +216,57 @@ describe("License Utils", () => {
);
});
test("returns self-hosted AI smart tools from license", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, aiSmartTools: true },
});
test("uses cloud AI data analysis entitlement", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true);
const result = await getIsAIDataAnalysisEnabled("org_1");
const result = await getIsAISmartToolsEnabled("org_1");
expect(result).toBe(true);
expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith(
"org_1",
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS
);
});
test("returns false for self-hosted AI smart tools when not enabled", async () => {
test("returns self-hosted AI features from license", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, aiSmartTools: false },
features: {
...defaultFeatures,
aiSmartTools: true,
aiDataAnalysis: true,
},
});
const result = await getIsAISmartToolsEnabled("org_1");
expect(result).toBe(false);
const [smartTools, dataAnalysis] = await Promise.all([
getIsAISmartToolsEnabled("org_1"),
getIsAIDataAnalysisEnabled("org_1"),
]);
expect(smartTools).toBe(true);
expect(dataAnalysis).toBe(true);
});
test("returns false for self-hosted AI features when not enabled", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: {
...defaultFeatures,
aiSmartTools: false,
aiDataAnalysis: false,
},
});
const [smartTools, dataAnalysis] = await Promise.all([
getIsAISmartToolsEnabled("org_1"),
getIsAIDataAnalysisEnabled("org_1"),
]);
expect(smartTools).toBe(false);
expect(dataAnalysis).toBe(false);
});
test("uses cloud feedback record directories entitlement", async () => {
+12 -1
View File
@@ -31,7 +31,13 @@ const getCustomPlanFeaturePermission = async (
organizationId: string,
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
"accessControl" | "quotas" | "contacts" | "aiSmartTools" | "feedbackDirectories" | "dashboards"
| "accessControl"
| "quotas"
| "contacts"
| "aiSmartTools"
| "aiDataAnalysis"
| "feedbackDirectories"
| "dashboards"
>
): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) {
@@ -40,6 +46,7 @@ const getCustomPlanFeaturePermission = async (
quotas: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.QUOTA_MANAGEMENT,
contacts: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CONTACTS,
aiSmartTools: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS,
aiDataAnalysis: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS,
feedbackDirectories: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.FEEDBACK_DIRECTORIES,
dashboards: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.DASHBOARDS,
};
@@ -119,6 +126,10 @@ export const getIsAISmartToolsEnabled = async (organizationId: string): Promise<
return getCustomPlanFeaturePermission(organizationId, "aiSmartTools");
};
export const getIsAIDataAnalysisEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "aiDataAnalysis");
};
export const getIsAuditLogsEnabled = async (): Promise<boolean> => {
if (!AUDIT_LOG_ENABLED) return false;
return getSpecificFeatureFlag("auditLogs");
@@ -15,6 +15,7 @@ const ZEnterpriseLicenseFeatures = z.object({
saml: z.boolean(),
spamProtection: z.boolean(),
aiSmartTools: z.boolean(),
aiDataAnalysis: z.boolean(),
auditLogs: z.boolean(),
accessControl: z.boolean(),
quotas: z.boolean(),
@@ -28,6 +28,7 @@ describe("getFirstOrganization", () => {
whitelabel: null,
updatedAt: new Date(),
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
vi.mocked(prisma.organization.findFirst).mockResolvedValue(org);
const result = await getFirstOrganization();
@@ -46,6 +46,7 @@ export const mockOrganization: TOrganization = {
id: "org-123",
name: "Test Organization",
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: {
logoUrl: null,
faviconUrl: null,
@@ -145,6 +145,26 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(false);
});
test("returns true when license active and ai-data-analysis mapped feature enabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-data-analysis"],
licenseStatus: "active",
licenseFeatures: { aiDataAnalysis: true } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(true);
});
test("returns false when license active but ai-data-analysis mapped feature disabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-data-analysis"],
licenseStatus: "active",
licenseFeatures: { aiDataAnalysis: false } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(false);
});
test("returns true when license active and feature has no license mapping", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
@@ -11,6 +11,7 @@ const LICENSE_GUARDED_ENTITLEMENTS: Partial<Record<string, keyof TEnterpriseLice
"spam-protection": "spamProtection",
contacts: "contacts",
"ai-smart-tools": "aiSmartTools",
"ai-data-analysis": "aiDataAnalysis",
"feedback-directories": "feedbackDirectories",
dashboards: "dashboards",
};
@@ -111,6 +111,35 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-smart-tools");
expect(result.features).not.toContain("ai-data-analysis");
});
test("maps aiDataAnalysis feature to ai-data-analysis entitlement", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiDataAnalysis: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-data-analysis");
expect(result.features).not.toContain("ai-smart-tools");
});
test("maps both AI features when both are enabled", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiSmartTools: true, aiDataAnalysis: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-smart-tools");
expect(result.features).toContain("ai-data-analysis");
});
test("maps feedbackDirectories feature to feedback-directories entitlement", async () => {
@@ -30,6 +30,9 @@ const mapLicenseFeaturesToEntitlements = (
if (features.aiSmartTools) {
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS);
}
if (features.aiDataAnalysis) {
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS);
}
if (features.feedbackDirectories) {
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.FEEDBACK_DIRECTORIES);
}
@@ -6,7 +6,6 @@ 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";
@@ -148,35 +147,17 @@ const parseTenantId = (tenantId: string | null): string | null => {
return ZId.safeParse(tenantId).success ? tenantId : null;
};
const parseJsonBody = async (
request: NextRequest
): Promise<
| {
ok: true;
body: Record<string, unknown> | null;
}
| {
ok: false;
response: Response;
}
> => {
const parseJsonBody = async (request: NextRequest): Promise<Record<string, unknown> | null> => {
try {
const rawBody = await readRequestBodyWithLimit(request);
const rawBody = await request.text();
if (!rawBody.trim()) {
return { ok: true, body: null };
return null;
}
const parsedBody = JSON.parse(rawBody);
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 };
return parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : null;
} catch {
return null;
}
};
@@ -227,12 +208,7 @@ const resolveTenantId = async (
}
if (route.tenantSource === "body") {
const parseResult = await parseJsonBody(request);
if (!parseResult.ok) {
return { errorResponse: parseResult.response };
}
const body = parseResult.body;
const body = await parseJsonBody(request);
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
if (!tenantId) {
return {
-43
View File
@@ -5,7 +5,6 @@ import {
createFeedbackRecord,
createFeedbackRecordsBatch,
deleteFeedbackRecord,
deleteHubTenantData,
getFeedbackRecordTenant,
listFeedbackRecords,
retrieveFeedbackRecord,
@@ -345,48 +344,6 @@ describe("hub service", () => {
});
});
describe("deleteHubTenantData", () => {
test("returns config error when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await deleteHubTenantData("tenant-1");
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns mapped data when client.delete resolves", async () => {
const deleteSpy = vi.fn().mockResolvedValue({
tenant_id: "tenant-1",
deleted_feedback_records: 3,
deleted_embeddings: 5,
deleted_webhooks: 1,
});
vi.mocked(getHubClient).mockReturnValue({ delete: deleteSpy } as any);
const result = await deleteHubTenantData("tenant-1");
expect(deleteSpy).toHaveBeenCalledWith("/v1/tenants/tenant-1/data");
expect(result.error).toBeNull();
expect(result.data).toEqual({
deletedFeedbackRecords: 3,
deletedEmbeddings: 5,
deletedWebhooks: 1,
});
});
test("returns error when client.delete throws", async () => {
vi.mocked(getHubClient).mockReturnValue({
delete: vi.fn().mockRejectedValue(new Error("network")),
} as any);
const result = await deleteHubTenantData("tenant-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 0, message: "network" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
-51
View File
@@ -129,57 +129,6 @@ export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecor
}
};
export type HubTenantDataDeleteResult = {
data: {
deletedFeedbackRecords: number;
deletedEmbeddings: number;
deletedWebhooks: number;
} | null;
error: HubError | null;
};
type TenantDataDeleteResponse = {
tenant_id: string;
deleted_feedback_records: number;
deleted_embeddings: number;
deleted_webhooks: number;
message?: string;
};
/**
* Purge all Hub-owned data (feedback records, derived embeddings, webhooks) for a tenant.
* Called when the owning organization is deleted so Hub-side rows don't become orphaned.
* Idempotent on the Hub side; the caller treats failures as best-effort.
*
* Hits `DELETE /v1/tenants/{tenant_id}/data` directly because the SDK doesn't yet expose
* a typed method for this endpoint.
*/
export const deleteHubTenantData = async (tenantId: string): Promise<HubTenantDataDeleteResult> => {
const client = getHubClient();
if (!client) {
return { data: null, error: { ...NO_CONFIG_ERROR } };
}
try {
const data = await client.delete<TenantDataDeleteResponse>(
`/v1/tenants/${encodeURIComponent(tenantId)}/data`
);
return {
data: {
deletedFeedbackRecords: data.deleted_feedback_records,
deletedEmbeddings: data.deleted_embeddings,
deletedWebhooks: data.deleted_webhooks,
},
error: null,
};
} catch (err) {
logger.warn({ err, tenantId }, "Hub: deleteHubTenantData failed");
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
}
};
export type ListFeedbackRecordsResult = {
data: FeedbackRecordListResponse | null;
error: HubError | null;
@@ -6,8 +6,6 @@ import { processResponsePipelineJob } from "./process-response-pipeline-job";
const {
mockFetch,
mockCaptureSurveyResponsePostHogEvent,
mockCreatePinnedDispatcher,
mockDispatcherDestroy,
mockGetIntegrations,
mockGetResponseCountBySurveyId,
mockHandleIntegrations,
@@ -23,16 +21,13 @@ const {
mockSendFollowUpsForResponse,
mockSendResponseFinishedEmail,
mockSendTelemetryEvents,
mockValidateAndResolveWebhookUrl,
mockValidateWebhookUrl,
} = vi.hoisted(() => {
process.env.HUB_API_URL ??= "https://hub.test";
const dispatcherDestroy = vi.fn().mockResolvedValue(undefined);
return {
mockFetch: vi.fn(),
mockCaptureSurveyResponsePostHogEvent: vi.fn(),
mockCreatePinnedDispatcher: vi.fn(() => ({ destroy: dispatcherDestroy })),
mockDispatcherDestroy: dispatcherDestroy,
mockGetIntegrations: vi.fn(),
mockGetResponseCountBySurveyId: vi.fn(),
mockHandleIntegrations: vi.fn(),
@@ -48,7 +43,7 @@ const {
mockSendFollowUpsForResponse: vi.fn(),
mockSendResponseFinishedEmail: vi.fn(),
mockSendTelemetryEvents: vi.fn(),
mockValidateAndResolveWebhookUrl: vi.fn(),
mockValidateWebhookUrl: vi.fn(),
};
});
@@ -85,7 +80,6 @@ vi.mock(import("@/lib/constants"), async (importOriginal) => {
return {
...actual,
POSTHOG_KEY: undefined,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
};
});
@@ -110,8 +104,7 @@ vi.mock("./posthog", () => ({
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateAndResolveWebhookUrl: mockValidateAndResolveWebhookUrl,
createPinnedDispatcher: mockCreatePinnedDispatcher,
validateWebhookUrl: mockValidateWebhookUrl,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
@@ -212,9 +205,7 @@ describe("processResponsePipelineJob", () => {
mockPrismaUserFindMany.mockResolvedValue([]);
mockGetResponseCountBySurveyId.mockResolvedValue(1);
mockHandleIntegrations.mockResolvedValue(undefined);
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "93.184.216.34", family: 4 });
mockDispatcherDestroy.mockResolvedValue(undefined);
mockCreatePinnedDispatcher.mockImplementation(() => ({ destroy: mockDispatcherDestroy }));
mockValidateWebhookUrl.mockResolvedValue(undefined);
mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined);
mockRecordResponseCreatedMeterEvent.mockResolvedValue(undefined);
mockSendResponseFinishedEmail.mockResolvedValue(undefined);
@@ -251,7 +242,7 @@ describe("processResponsePipelineJob", () => {
triggers: { has: "responseCreated" },
},
});
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockValidateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
@@ -490,7 +481,7 @@ describe("processResponsePipelineJob", () => {
url: "https://example.com/webhook",
},
]);
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
mockValidateWebhookUrl.mockRejectedValue(webhookError);
await expect(
processResponsePipelineJob(
@@ -536,7 +527,7 @@ describe("processResponsePipelineJob", () => {
locale: "en",
},
]);
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
mockValidateWebhookUrl.mockRejectedValue(webhookError);
await expect(
processResponsePipelineJob(
@@ -789,133 +780,6 @@ describe("processResponsePipelineJob", () => {
);
});
test("pins fetch to the resolved webhook IP via undici dispatcher", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
const pinnedDispatcher = { destroy: mockDispatcherDestroy };
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "203.0.113.10", family: 4 });
mockCreatePinnedDispatcher.mockReturnValue(pinnedDispatcher);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockCreatePinnedDispatcher).toHaveBeenCalledWith({ ip: "203.0.113.10", family: 4 });
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
dispatcher: pinnedDispatcher,
redirect: "manual",
})
);
});
test("blocks 3xx redirects from webhook endpoints as delivery failures", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
mockFetch.mockResolvedValue({
ok: false,
status: 302,
});
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(
"Webhook delivery blocked: redirect status 302"
);
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({ redirect: "manual" })
);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
webhookId: "webhook_123",
}),
"Response pipeline webhook delivery failed"
);
});
test("destroys the pinned dispatcher after a successful webhook delivery", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockDispatcherDestroy).toHaveBeenCalledTimes(1);
});
test("logs dispatcher cleanup failures without failing a successful webhook delivery", async () => {
const cleanupError = new Error("destroy failed");
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
mockDispatcherDestroy.mockRejectedValue(cleanupError);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.objectContaining({
err: cleanupError,
webhookId: "webhook_123",
webhookUrl: "https://example.com/webhook",
}),
"Response pipeline webhook dispatcher cleanup failed"
);
});
test("destroys the pinned dispatcher when the webhook fetch throws", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
mockFetch.mockRejectedValue(new Error("connect refused"));
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow("connect refused");
expect(mockDispatcherDestroy).toHaveBeenCalledTimes(1);
});
test("does not pin a dispatcher when the resolver returns null (internal URL flag)", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "http://localhost:3000/webhook",
},
]);
mockValidateAndResolveWebhookUrl.mockResolvedValue(null);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockCreatePinnedDispatcher).not.toHaveBeenCalled();
expect(mockDispatcherDestroy).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3000/webhook",
expect.objectContaining({ dispatcher: undefined })
);
});
test("classifies database pool exhaustion as retryable and logs a warning", async () => {
const poolExhaustionError = new Error("Timed out fetching a new connection from the connection pool");
mockPrismaSurveyFindUnique.mockRejectedValue(poolExhaustionError);
@@ -7,11 +7,11 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { type TUserLocale, ZUserLocale } from "@formbricks/types/user";
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
import { type TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
@@ -136,13 +136,9 @@ const createWebhookMessageId = ({
webhookId: string;
}): string => createHash("sha256").update(`${jobId}:${webhookId}:${event}`).digest("hex");
type WebhookFetchOptions = RequestInit & {
dispatcher?: ReturnType<typeof createPinnedDispatcher>;
};
const fetchWithTimeout = async (
url: string,
options: WebhookFetchOptions,
options: RequestInit,
timeoutMs: number = WEBHOOK_TIMEOUT_MS
): Promise<Response> => {
const abortController = new AbortController();
@@ -157,7 +153,7 @@ const fetchWithTimeout = async (
return await fetch(url, {
...options,
signal,
} as RequestInit);
});
} finally {
clearTimeout(timeoutId);
}
@@ -226,47 +222,15 @@ const createWebhookDeliveryTask = async ({
);
}
const address = await validateAndResolveWebhookUrl(webhook.url);
// Pin TCP connect to the validated IP — closes DNS-rebinding TOCTOU between
// validation and fetch. Skip pinning when address is null (DANGEROUSLY flag +
// blocked name resolved via /etc/hosts).
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
// `redirect: "manual"` blocks 30x-based SSRF to private/internal hosts.
// Gated on the same env var as URL validation for self-hosters who opted in.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
await validateWebhookUrl(webhook.url);
const response = await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
});
try {
const response = await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
redirect: redirectMode,
dispatcher,
});
// With `redirect: "manual"`, undici returns the actual 30x (not opaqueredirect).
// Treat as delivery failure so redirect-based SSRF cannot silently succeed.
if (response.status >= 300 && response.status < 400) {
throw new Error(`Webhook delivery blocked: redirect status ${response.status}`);
}
if (!response.ok) {
throw new Error(`Webhook delivery failed with status ${response.status}`);
}
} finally {
try {
await dispatcher?.destroy();
} catch (cleanupError) {
logger.warn(
{
...logContext,
err: cleanupError,
webhookId: webhook.id,
webhookUrl: webhook.url,
},
"Response pipeline webhook dispatcher cleanup failed"
);
}
if (!response.ok) {
throw new Error(`Webhook delivery failed with status ${response.status}`);
}
} catch (error) {
logger.error(
@@ -77,9 +77,11 @@ describe("getOrganizationAIKeys", () => {
const mockOrgId = "org_test789";
const mockOrganizationData: {
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
billing: TOrganizationBilling;
} = {
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
billing: {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
@@ -102,6 +104,7 @@ describe("getOrganizationAIKeys", () => {
},
select: {
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
billing: {
select: {
stripeCustomerId: true,
@@ -22,6 +22,7 @@ export const getOrganizationAIKeys = reactCache(
organizationId: string
): Promise<{
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
billing: TOrganizationBilling;
} | null> => {
try {
@@ -31,6 +32,7 @@ export const getOrganizationAIKeys = reactCache(
},
select: {
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
billing: {
select: {
stripeCustomerId: true,
@@ -48,6 +50,7 @@ export const getOrganizationAIKeys = reactCache(
return {
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
billing: {
stripeCustomerId: organization.billing.stripeCustomerId,
limits: organization.billing.limits as TOrganizationBilling["limits"],
@@ -145,6 +145,7 @@ export const ManageTranslationsModal = ({
const errorMessages: Record<string, string> = {
ai_features_not_enabled: t("workspace.surveys.edit.ai_features_not_enabled"),
ai_smart_tools_disabled: t("workspace.surveys.edit.ai_smart_tools_disabled"),
ai_data_analysis_disabled: t("workspace.surveys.edit.ai_data_analysis_disabled"),
ai_instance_not_configured: t("workspace.surveys.edit.ai_instance_not_configured"),
};
return errorMessages[errorCode] ?? errorCode;
@@ -1,7 +1,7 @@
"use client";
import { Workspace } from "@prisma/client";
import { MotionConfig, motion } from "framer-motion";
import { motion } from "framer-motion";
import { ExpandIcon, GlobeIcon, MonitorIcon, ShrinkIcon, SmartphoneIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -202,180 +202,70 @@ export const PreviewSurvey = ({
};
return (
<MotionConfig reducedMotion="user">
<div
className="flex h-full w-full flex-col items-center justify-items-center p-2 py-4"
id="survey-preview">
<motion.div
className={cn(
"z-50 flex h-full w-fit items-center justify-center",
isFullScreenPreview && "h-full w-full bg-zinc-500/50 backdrop-blur-md"
)}
style={{
position: isFullScreenPreview ? "fixed" : "absolute",
zIndex: 50,
left: isFullScreenPreview ? 0 : undefined,
top: isFullScreenPreview ? 0 : undefined,
}}
transition={{
ease: "easeInOut",
delay: 1.5,
}}
/>
<motion.div
layout
style={{
left: isFullScreenPreview ? "2.5%" : undefined,
top: isFullScreenPreview ? 0 : undefined,
}}
transition={{
duration: 0.8,
ease: "easeInOut",
type: "spring",
}}
className={cn(
"z-50 flex h-[95%] w-full items-center justify-center overflow-hidden rounded-lg border border-slate-300",
isFullScreenPreview && "absolute z-50 h-[95%] w-[95%]"
)}>
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
{t("common.preview")}
</p>
<div className="absolute right-0 top-0 m-2 flex items-center gap-1">
{showLanguageSelector && (
<LanguageSelector
languages={enabledLanguages}
languageCode={languageCode}
setLanguageCode={setLanguageCode}
locale={locale}
/>
)}
<ResetProgressButton onClick={resetProgress} />
</div>
<MediaBackground
surveyType={survey.type}
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isMobilePreview>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={placement}
previewMode="mobile"
overlay={overlay}
clickOutsideClose={clickOutsideClose}
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey(survey)}
isBrandingEnabled={workspace.inAppSurveyBranding}
isRedirectDisabled={true}
languageCode={languageCode}
styling={styling}
isCardBorderVisible={!styling.highlightBorderColor?.light}
onClose={handlePreviewModalClose}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
onFinished={onFinished}
placement={placement}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</Modal>
) : (
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo workspaceLogo={workspace.logo} surveyLogo={styling.logo} previewSurvey />
)}
</div>
<div className="z-10 w-full rounded-lg border border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
isBrandingEnabled={workspace.linkSurveyBranding}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
languageCode={languageCode}
responseCount={42}
styling={styling}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</div>
</div>
)}
</MediaBackground>
</>
)}
{previewMode === "desktop" && (
<div className="flex h-full w-full flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<button
className="h-3 w-3 cursor-pointer rounded-full bg-emerald-500"
onClick={() => {
if (isFullScreenPreview) {
setIsFullScreenPreview(false);
} else {
setIsFullScreenPreview(true);
}
}}
aria-label={
isFullScreenPreview
? t("workspace.surveys.edit.shrink_preview")
: t("workspace.surveys.edit.expand_preview")
}></button>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>
{previewType === "modal" ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}
</p>
<div className="flex items-center">
{showLanguageSelector && (
<LanguageSelector
languages={enabledLanguages}
languageCode={languageCode}
setLanguageCode={setLanguageCode}
locale={locale}
/>
)}
{isFullScreenPreview ? (
<ShrinkIcon
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
onClick={() => {
setIsFullScreenPreview(false);
}}
/>
) : (
<ExpandIcon
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
onClick={() => {
setIsFullScreenPreview(true);
}}
/>
)}
<ResetProgressButton onClick={resetProgress} />
</div>
</div>
</div>
<div
className="flex h-full w-full flex-col items-center justify-items-center p-2 py-4"
id="survey-preview">
<motion.div
className={cn(
"z-50 flex h-full w-fit items-center justify-center",
isFullScreenPreview && "h-full w-full bg-zinc-500/50 backdrop-blur-md"
)}
style={{
position: isFullScreenPreview ? "fixed" : "absolute",
zIndex: 50,
left: isFullScreenPreview ? 0 : undefined,
top: isFullScreenPreview ? 0 : undefined,
}}
transition={{
ease: "easeInOut",
delay: 1.5,
}}
/>
<motion.div
layout
style={{
left: isFullScreenPreview ? "2.5%" : undefined,
top: isFullScreenPreview ? 0 : undefined,
}}
transition={{
duration: 0.8,
ease: "easeInOut",
type: "spring",
}}
className={cn(
"z-50 flex h-[95%] w-full items-center justify-center overflow-hidden rounded-lg border border-slate-300",
isFullScreenPreview && "absolute z-50 h-[95%] w-[95%]"
)}>
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
{t("common.preview")}
</p>
<div className="absolute right-0 top-0 m-2 flex items-center gap-1">
{showLanguageSelector && (
<LanguageSelector
languages={enabledLanguages}
languageCode={languageCode}
setLanguageCode={setLanguageCode}
locale={locale}
/>
)}
<ResetProgressButton onClick={resetProgress} />
</div>
<MediaBackground
surveyType={survey.type}
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isMobilePreview>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={placement}
clickOutsideClose={clickOutsideClose}
previewMode="mobile"
overlay={overlay}
previewMode="desktop"
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
clickOutsideClose={clickOutsideClose}
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
@@ -390,28 +280,23 @@ export const PreviewSurvey = ({
setBlockId = f;
}}
onFinished={onFinished}
isSpamProtectionEnabled={isSpamProtectionEnabled}
placement={placement}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</Modal>
) : (
<MediaBackground
surveyType={survey.type}
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo workspaceLogo={workspace.logo} surveyLogo={styling.logo} previewSurvey />
)}
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
<div className="z-10 w-full rounded-lg border border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
isBrandingEnabled={workspace.linkSurveyBranding}
isRedirectDisabled={true}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
languageCode={languageCode}
responseCount={42}
styling={styling}
@@ -421,27 +306,140 @@ export const PreviewSurvey = ({
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</div>
</MediaBackground>
</div>
)}
</div>
)}
</motion.div>
</MediaBackground>
</>
)}
{previewMode === "desktop" && (
<div className="flex h-full w-full flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<button
className="h-3 w-3 cursor-pointer rounded-full bg-emerald-500"
onClick={() => {
if (isFullScreenPreview) {
setIsFullScreenPreview(false);
} else {
setIsFullScreenPreview(true);
}
}}
aria-label={
isFullScreenPreview
? t("workspace.surveys.edit.shrink_preview")
: t("workspace.surveys.edit.expand_preview")
}></button>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>
{previewType === "modal" ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}
</p>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<TabOption
active={previewMode === "mobile"}
icon={<SmartphoneIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => handlePreviewModeChange("mobile")}
/>
<TabOption
active={previewMode === "desktop"}
icon={<MonitorIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => handlePreviewModeChange("desktop")}
/>
</div>
<div className="flex items-center">
{showLanguageSelector && (
<LanguageSelector
languages={enabledLanguages}
languageCode={languageCode}
setLanguageCode={setLanguageCode}
locale={locale}
/>
)}
{isFullScreenPreview ? (
<ShrinkIcon
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
onClick={() => {
setIsFullScreenPreview(false);
}}
/>
) : (
<ExpandIcon
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
onClick={() => {
setIsFullScreenPreview(true);
}}
/>
)}
<ResetProgressButton onClick={resetProgress} />
</div>
</div>
</div>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={placement}
clickOutsideClose={clickOutsideClose}
overlay={overlay}
previewMode="desktop"
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey(survey)}
isBrandingEnabled={workspace.inAppSurveyBranding}
isRedirectDisabled={true}
languageCode={languageCode}
styling={styling}
isCardBorderVisible={!styling.highlightBorderColor?.light}
onClose={handlePreviewModalClose}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
onFinished={onFinished}
isSpamProtectionEnabled={isSpamProtectionEnabled}
placement={placement}
/>
</Modal>
) : (
<MediaBackground
surveyType={survey.type}
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo workspaceLogo={workspace.logo} surveyLogo={styling.logo} previewSurvey />
)}
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
isBrandingEnabled={workspace.linkSurveyBranding}
isRedirectDisabled={true}
languageCode={languageCode}
responseCount={42}
styling={styling}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</div>
</MediaBackground>
)}
</div>
)}
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<TabOption
active={previewMode === "mobile"}
icon={<SmartphoneIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => handlePreviewModeChange("mobile")}
/>
<TabOption
active={previewMode === "desktop"}
icon={<MonitorIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => handlePreviewModeChange("desktop")}
/>
</div>
</MotionConfig>
</div>
);
};
@@ -1,6 +1,6 @@
"use client";
import { MotionConfig, Variants, motion } from "framer-motion";
import { Variants, motion } from "framer-motion";
import { Fragment, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
@@ -131,113 +131,111 @@ export const ThemeStylingPreviewSurvey = ({
};
return (
<MotionConfig reducedMotion="user">
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
layout
variants={previewScreenVariants}
animate={
isFullScreenPreview
? previewPosition === "relative"
? "expanded"
: "expanded_with_fixed_positioning"
: "shrink"
}
className={cn(
"relative z-10 flex w-5/6 flex-col rounded-lg border border-slate-300 shadow-xl",
isAppSurvey ? "bg-slate-200" : "overflow-y-auto bg-white"
)}>
<div className="flex h-auto w-full items-center rounded-t-lg bg-slate-100 py-2">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{isAppSurvey ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}</p>
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
layout
variants={previewScreenVariants}
animate={
isFullScreenPreview
? previewPosition === "relative"
? "expanded"
: "expanded_with_fixed_positioning"
: "shrink"
}
className={cn(
"relative z-10 flex w-5/6 flex-col rounded-lg border border-slate-300 shadow-xl",
isAppSurvey ? "bg-slate-200" : "overflow-y-auto bg-white"
)}>
<div className="flex h-auto w-full items-center rounded-t-lg bg-slate-100 py-2">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{isAppSurvey ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}</p>
<div className="flex items-center">
<ResetProgressButton onClick={resetQuestionProgress} />
</div>
<div className="flex items-center">
<ResetProgressButton onClick={resetQuestionProgress} />
</div>
</div>
<div className="flex w-full flex-1 flex-col rounded-b-lg">
{isAppSurvey ? (
<Modal
isOpen
placement={placement}
clickOutsideClose={clickOutsideClose}
overlay={overlay}
previewMode="desktop"
background={workspace.styling.cardBackgroundColor?.light}
borderRadius={workspace.styling.roundness ?? 8}>
<Fragment key={surveyKey}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "app" })}
isBrandingEnabled={workspace.inAppSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
styling={styling}
isCardBorderVisible={!highlightBorderColor}
languageCode="default"
/>
</Fragment>
</Modal>
) : (
<MediaBackground
surveyType={survey.type}
styling={styling}
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
isEditorView>
{!workspace.styling?.isLogoHidden && (
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
<ClientLogo workspaceLogo={workspace.logo} previewSurvey />
</button>
)}
<div
key={surveyKey}
className={`${!workspace.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md overflow-hidden rounded-lg p-4`}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
isBrandingEnabled={workspace.linkSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
responseCount={42}
styling={styling}
languageCode="default"
/>
</div>
</MediaBackground>
)}
</div>
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<button
type="button"
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("link")}>
{t("common.link_survey")}
</button>
<button
type="button"
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("app")}>
{t("common.app_survey")}
</button>
</div>
<div className="flex w-full flex-1 flex-col rounded-b-lg">
{isAppSurvey ? (
<Modal
isOpen
placement={placement}
clickOutsideClose={clickOutsideClose}
overlay={overlay}
previewMode="desktop"
background={workspace.styling.cardBackgroundColor?.light}
borderRadius={workspace.styling.roundness ?? 8}>
<Fragment key={surveyKey}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "app" })}
isBrandingEnabled={workspace.inAppSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
styling={styling}
isCardBorderVisible={!highlightBorderColor}
languageCode="default"
/>
</Fragment>
</Modal>
) : (
<MediaBackground
surveyType={survey.type}
styling={styling}
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
isEditorView>
{!workspace.styling?.isLogoHidden && (
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
<ClientLogo workspaceLogo={workspace.logo} previewSurvey />
</button>
)}
<div
key={surveyKey}
className={`${!workspace.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md overflow-hidden rounded-lg p-4`}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
isBrandingEnabled={workspace.linkSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
responseCount={42}
styling={styling}
languageCode="default"
/>
</div>
</MediaBackground>
)}
</div>
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<button
type="button"
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("link")}>
{t("common.link_survey")}
</button>
<button
type="button"
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("app")}>
{t("common.app_survey")}
</button>
</div>
</MotionConfig>
</div>
);
};
+3
View File
@@ -119,6 +119,7 @@ export const workspaceIdLayoutChecks = async (workspaceId: string) => {
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
},
},
@@ -172,6 +173,7 @@ export const getWorkspaceWithRelations = reactCache(async (workspaceId: string,
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
memberships: {
where: { userId },
@@ -221,6 +223,7 @@ export const getWorkspaceWithRelations = reactCache(async (workspaceId: string,
name: data.organization.name,
billing: data.organization.billing,
isAISmartToolsEnabled: data.organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: data.organization.isAIDataAnalysisEnabled,
whitelabel: data.organization.whitelabel,
},
membership: data.organization.memberships[0] || null,
+5 -2
View File
@@ -2,11 +2,14 @@ dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 16.4.16
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 20.11.2
- name: gateway-helm
repository: oci://docker.io/envoyproxy
version: v1.7.1
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 20.11.2
digest: sha256:078652b37649ba5bb72f7463709356c08f464cb989270b97d095b8243c0e58b9
generated: "2026-05-21T12:52:44.141547+05:30"
digest: sha256:d93a29cb9cbef513db410ded5aa199f219c5b25ce1870e1e049dc470acc34235
generated: "2026-04-08T00:33:09.875832+05:30"
+6 -2
View File
@@ -1,6 +1,6 @@
apiVersion: v2
name: formbricks
description: A Helm chart for Formbricks with PostgreSQL, Valkey
description: A Helm chart for Formbricks with PostgreSQL, Redis
type: application
@@ -15,7 +15,7 @@ icon: https://formbricks.com/favicon.ico
keywords:
- formbricks
- postgresql
- valkey
- redis
home: https://formbricks.com/docs/self-hosting/setup/kubernetes
maintainers:
@@ -27,6 +27,10 @@ dependencies:
version: "16.4.16"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
- name: redis
version: 20.11.2
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled
- name: gateway-helm
alias: envoy
version: "v1.7.1"
+5 -32
View File
@@ -2,7 +2,7 @@
![Version: 0.0.0-dev](https://img.shields.io/badge/Version-0.0.0--dev-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 5.0.0-rc.1](https://img.shields.io/badge/AppVersion-5.0.0--rc.1-informational?style=flat-square)
A Helm chart for Formbricks with PostgreSQL, Valkey
A Helm chart for Formbricks with PostgreSQL, Redis
**Homepage:** <https://formbricks.com/docs/self-hosting/setup/kubernetes>
@@ -17,8 +17,9 @@ A Helm chart for Formbricks with PostgreSQL, Valkey
| Repository | Name | Version |
| ---------------------------------------- | ------------ | ------- |
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.4.16 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
| oci://docker.io/envoyproxy | gateway-helm | v1.7.1 |
| oci://registry-1.docker.io/bitnamicharts | envoyRedis | 20.11.2 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
## Envoy bundle modes
@@ -31,7 +32,7 @@ rate limiting.
Gateway API CRDs plus an Envoy Gateway controller compatible with
`envoy.config.envoyGateway.gateway.controllerName`.
- `envoyRedis.enabled=true` deploys a dedicated Redis replication + Sentinel bundle for Envoy RLS. It is intentionally
separate from the bundled app Valkey deployment.
separate from the existing app `redis` dependency.
- The bundled controller reads its Redis backend from `envoy.config.envoyGateway.rateLimit.backend.redis.url`.
If you enable Redis authentication or override `envoyRedis.fullnameOverride`, set that URL explicitly so the
controller points at the correct backend.
@@ -64,9 +65,6 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
When the Formbricks migration job is enabled, Hub waits for the `formbricks-migration` Job to complete before its own goose/river init migrations run. This keeps fresh shared-database installs from creating Hub tables before Prisma has initialized the Formbricks schema.
If the Job has already been cleaned up, Hub only continues after all expected Prisma and data migration success markers are present in the database.
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
```yaml
@@ -130,7 +128,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
| deployment.env | object | `{}` | |
| deployment.envFrom | string | `nil` | |
| deployment.image.digest | string | `""` | When set, takes precedence over tag. |
| deployment.image.digest | string | `""` | |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.image.tag | string | `""` | |
@@ -222,10 +220,6 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| hub.migration.waitForFormbricksMigration.enabled | bool | `true` | |
| hub.migration.waitForFormbricksMigration.intervalSeconds | int | `5` | |
| hub.migration.waitForFormbricksMigration.maxAttempts | int | `180` | |
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | Consecutive missing Job reads before using DB markers. |
| hub.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
@@ -295,28 +289,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| redis.enabled | bool | `true` | |
| redis.externalRedisUrl | string | `""` | |
| redis.fullnameOverride | string | `"formbricks-redis"` | |
| redis.image.digest | string | `"sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d"` | |
| redis.image.pullPolicy | string | `"IfNotPresent"` | |
| redis.image.repository | string | `"valkey/valkey"` | |
| redis.image.tag | string | `""` | |
| redis.master.affinity | object | `{}` | |
| redis.master.containerSecurityContext | object | `{}` | |
| redis.master.nodeSelector | object | `{}` | |
| redis.master.pdb.enabled | bool | `true` | |
| redis.master.pdb.maxUnavailable | int | `0` | |
| redis.master.pdb.minAvailable | string | `""` | |
| redis.master.persistence.accessModes[0] | string | `"ReadWriteOnce"` | |
| redis.master.persistence.enabled | bool | `true` | |
| redis.master.persistence.size | string | `"8Gi"` | |
| redis.master.persistence.storageClass | string | `""` | |
| redis.master.podAnnotations | object | `{}` | |
| redis.master.podSecurityContext | object | `{}` | |
| redis.master.resources.limits.cpu | string | `"150m"` | |
| redis.master.resources.limits.memory | string | `"192Mi"` | |
| redis.master.resources.requests.cpu | string | `"100m"` | |
| redis.master.resources.requests.memory | string | `"128Mi"` | |
| redis.master.tolerations | list | `[]` | |
| redis.master.topologySpreadConstraints | list | `[]` | |
| redis.networkPolicy.enabled | bool | `false` | |
| secret.enabled | bool | `true` | |
| service.additionalLabels | object | `{}` | |
+3 -5
View File
@@ -1,6 +1,6 @@
{{ .Release.Name | camelcase }} with {{ include "formbricks.deploymentImage" . }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
{{ .Release.Name | camelcase }} with {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
Here's how you can access and manage your deployment:
---
@@ -64,14 +64,12 @@ Redis Access:
{{- if .Values.redis.enabled }}
Redis is deployed within your cluster.
{{- if .Values.redis.auth.enabled }}
Retrieve the password using:
```sh
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.redisSecretName" . }} -o jsonpath="{.data.{{ include "formbricks.redisSecretKey" . }}}" | base64 --decode
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.REDIS_PASSWORD}" | base64 --decode
```
{{- end }}
Connection details:
- **Host**: `{{ include "formbricks.redisMasterName" . }}`
- **Host**: `{{ include "formbricks.name" . }}-redis-master`
- **Port**: `6379`
{{- else if .Values.redis.externalRedisUrl }}
You're using an external Redis instance.
+21 -85
View File
@@ -125,57 +125,10 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
{{- end }}
{{- define "formbricks.redisName" -}}
{{- .Values.redis.fullnameOverride | default (printf "%s-redis" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisMasterName" -}}
{{- printf "%s-master" (include "formbricks.redisName" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisHeadlessName" -}}
{{- printf "%s-headless" (include "formbricks.redisName" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "formbricks.redisImage" -}}
{{- if .Values.redis.image.digest -}}
{{- printf "%s@%s" .Values.redis.image.repository .Values.redis.image.digest -}}
{{- else -}}
{{- printf "%s:%s" .Values.redis.image.repository .Values.redis.image.tag -}}
{{- end -}}
{{- end }}
{{- define "formbricks.redisSecretName" -}}
{{- .Values.redis.auth.existingSecret | default (include "formbricks.appSecretName" .) -}}
{{- end }}
{{- define "formbricks.redisSecretKey" -}}
{{- .Values.redis.auth.existingSecretPasswordKey | default "REDIS_PASSWORD" -}}
{{- end }}
{{- define "formbricks.migrationJobName" -}}
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{/*
Formbricks application image reference. A configured digest takes precedence over the tag.
*/}}
{{- define "formbricks.deploymentImage" -}}
{{- if .Values.deployment.image.digest -}}
{{- printf "%s@%s" .Values.deployment.image.repository .Values.deployment.image.digest -}}
{{- else -}}
{{- printf "%s:%s" .Values.deployment.image.repository (.Values.deployment.image.tag | default .Chart.AppVersion | default "latest") -}}
{{- end -}}
{{- end }}
{{- define "formbricks.hubSecretName" -}}
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
{{- end }}
{{- define "formbricks.hubMigrationWaitServiceAccountName" -}}
{{- printf "%s-migration-wait" (include "formbricks.hubname" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{/*
Hub image reference. Pin by digest in production (hub.image.digest = "sha256:..."); falls back to
hub.image.tag for local/dev. All Hub workloads (deployment, init container, migration job, future
@@ -244,9 +197,8 @@ Embedding API key value for the generated embeddings secret.
{{- $secretName := include "formbricks.hubEmbeddingsSecretName" . }}
{{- $secretKey := .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData $secretKey }}
{{- index $secretData $secretKey | b64dec -}}
{{- if and $secret (index $secret.data $secretKey) }}
{{- index $secret.data $secretKey | b64dec -}}
{{- else if .Values.hub.embeddings.auth.apiKey }}
{{- .Values.hub.embeddings.auth.apiKey -}}
{{- else }}
@@ -292,9 +244,8 @@ true
{{- define "formbricks.postgresAdminPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "POSTGRES_ADMIN_PASSWORD" }}
{{- index $secretData "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
{{- if and $secret (index $secret.data "POSTGRES_ADMIN_PASSWORD") }}
{{- index $secret.data "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
{{- else }}
{{- randAlphaNum 16 -}}
{{- end -}}
@@ -302,34 +253,27 @@ true
{{- define "formbricks.postgresUserPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "POSTGRES_USER_PASSWORD" }}
{{- index $secretData "POSTGRES_USER_PASSWORD" | b64dec -}}
{{- if and $secret (index $secret.data "POSTGRES_USER_PASSWORD") }}
{{- index $secret.data "POSTGRES_USER_PASSWORD" | b64dec -}}
{{- else }}
{{- randAlphaNum 16 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.redisPassword" -}}
{{- $redisSecretName := include "formbricks.redisSecretName" . }}
{{- $redisSecretKey := include "formbricks.redisSecretKey" . }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $redisSecretName) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData $redisSecretKey }}
{{- index $secretData $redisSecretKey | b64dec -}}
{{- else if eq $redisSecretName (include "formbricks.appSecretName" .) }}
{{- randAlphaNum 16 -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if and $secret (index $secret.data "REDIS_PASSWORD") }}
{{- index $secret.data "REDIS_PASSWORD" | b64dec -}}
{{- else }}
{{- fail (printf "redis.auth.existingSecret %q must already exist in namespace %q and contain %s when secret.enabled=true so REDIS_URL can use the same password as the bundled Valkey server. Disable secret.enabled and provide app-secrets externally, or pre-create the Redis auth secret." $redisSecretName .Release.Namespace $redisSecretKey) -}}
{{- randAlphaNum 16 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "CRON_SECRET" }}
{{- index $secretData "CRON_SECRET" | b64dec -}}
{{- else if and $secret (hasKey $secret "data") }}
{{- if and $secret (index $secret.data "CRON_SECRET") }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else if $secret }}
{{- fail (printf "Secret %q exists in namespace %q but is missing CRON_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- else }}
{{- randAlphaNum 32 -}}
@@ -338,11 +282,8 @@ true
{{- define "formbricks.encryptionKey" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "ENCRYPTION_KEY" }}
{{- index $secretData "ENCRYPTION_KEY" | b64dec -}}
{{- else if and $secret (hasKey $secret "data") }}
{{- fail (printf "Secret %q exists in namespace %q but is missing ENCRYPTION_KEY" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- if $secret }}
{{- index $secret.data "ENCRYPTION_KEY" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
@@ -350,11 +291,8 @@ true
{{- define "formbricks.nextAuthSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "NEXTAUTH_SECRET" }}
{{- index $secretData "NEXTAUTH_SECRET" | b64dec -}}
{{- else if and $secret (hasKey $secret "data") }}
{{- fail (printf "Secret %q exists in namespace %q but is missing NEXTAUTH_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- if $secret }}
{{- index $secret.data "NEXTAUTH_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
@@ -363,9 +301,8 @@ true
{{- define "formbricks.hubApiKey" -}}
{{- $hubSecretName := include "formbricks.hubSecretName" . }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $hubSecretName) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "HUB_API_KEY" }}
{{- index $secretData "HUB_API_KEY" | b64dec -}}
{{- if and $secret (index $secret.data "HUB_API_KEY") }}
{{- index $secret.data "HUB_API_KEY" | b64dec -}}
{{- else if .Values.hub.existingSecret }}
{{- fail (printf "hub.existingSecret %q must already exist in namespace %q and contain HUB_API_KEY when rendering the generated app secret. Disable secret.enabled and provide app-secrets externally, or pre-create the Hub secret." $hubSecretName .Release.Namespace) -}}
{{- else }}
@@ -375,9 +312,8 @@ true
{{- define "formbricks.cubejsApiSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "CUBEJS_API_SECRET" }}
{{- index $secretData "CUBEJS_API_SECRET" | b64dec -}}
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}

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