Compare commits

..

2 Commits

Author SHA1 Message Date
Bhagya Amarasinghe c29ee83e19 fix(webhooks): ignore dispatcher cleanup failures 2026-05-21 18:19:58 +05:30
pandeymangg 0952fee6c7 pin DNS and block redirects on webhook delivery 2026-05-21 14:29:56 +05:30
96 changed files with 685 additions and 1041 deletions
@@ -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,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new InvalidInputError("No contacts found for the selected segment");
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -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");
}
@@ -51,6 +51,7 @@ const mockOrganization: TOrganization = {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithWorkspaceId["followUps"][number] = {
@@ -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
View File
@@ -1859,7 +1859,6 @@ checksums:
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
@@ -1894,7 +1893,6 @@ checksums:
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
+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);
@@ -73,6 +73,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -125,6 +126,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -177,6 +179,7 @@ describe("Organization Service", () => {
updatedAt: new Date(),
billing: expectedBilling,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -236,6 +239,7 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
workspaces: [
@@ -277,6 +281,7 @@ describe("Organization Service", () => {
usageCycleAnchor: expect.any(Date),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
+2
View File
@@ -35,6 +35,7 @@ export const select = {
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -73,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"],
});
@@ -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,
},
];
+3 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",
"attribute_key_required": "Schlüssel ist erforderlich",
"attribute_key_reserved_future_default": "Der Schlüssel ist für zukünftige Standardattribute reserviert ({reservedKeys}). Bitte wähle einen anderen Schlüssel.",
"attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen",
"attribute_label": "Bezeichnung",
"attribute_label_placeholder": "z. B. Geburtsdatum",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Persönlichen Link erstellen",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu erstellen.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_csv_reserved_column_names": "Reservierte CSV-Spaltennamen: {columns}. Diese Namen sind für zukünftige Standardattribute ({reservedKeys}) reserviert und können nicht als neue Attribute erstellt werden.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_activity_yet": "Noch keine Aktivität",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
"attribute_key_required": "Key is required",
"attribute_key_reserved_future_default": "Key is reserved for future default attributes ({reservedKeys}). Please choose a different key.",
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
"attribute_label": "Label",
"attribute_label_placeholder": "e.g. Date of Birth",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
"invalid_csv_reserved_column_names": "Reserved CSV column name(s): {columns}. These names are reserved for future default attributes ({reservedKeys}) and cannot be created as new attributes.",
"invalid_date_format": "Invalid date format. Please use a valid date.",
"invalid_number_format": "Invalid number format. Please enter a valid number.",
"no_activity_yet": "No activity yet",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",
"attribute_key_required": "La clave es obligatoria",
"attribute_key_reserved_future_default": "La clave está reservada para atributos predeterminados futuros ({reservedKeys}). Por favor, elige una clave diferente.",
"attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generar enlace personal",
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
"invalid_csv_reserved_column_names": "Nombre(s) de columna CSV reservado(s): {columns}. Estos nombres están reservados para atributos predeterminados futuros ({reservedKeys}) y no se pueden crear como nuevos atributos.",
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
"no_activity_yet": "Aún no hay actividad",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",
"attribute_key_required": "La clé est requise",
"attribute_key_reserved_future_default": "La clé est réservée pour les attributs par défaut futurs ({reservedKeys}). Veuillez choisir une clé différente.",
"attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr: uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre",
"attribute_label": "Étiquette",
"attribute_label_placeholder": "ex. Date de naissance",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
"invalid_csv_reserved_column_names": "Nom(s) de colonne CSV réservé(s) : {columns}. Ces noms sont réservés pour les attributs par défaut futurs ({reservedKeys}) et ne peuvent pas être créés en tant que nouveaux attributs.",
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
"no_activity_yet": "Aucune activité pour le moment",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók. Betűvel kell kezdődnie.",
"attribute_key_placeholder": "például: szuletesi_ido",
"attribute_key_required": "A kulcs kötelező",
"attribute_key_reserved_future_default": "A kulcs le van foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}). Kérem, válasszon egy másik kulcsot.",
"attribute_key_safe_identifier_required": "A kulcs csak biztonságos azonosító lehet: csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók, és betűvel kell kezdődnie",
"attribute_label": "Címke",
"attribute_label_placeholder": "például: Születési idő",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Személyes hivatkozás előállítása",
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
"invalid_csv_reserved_column_names": "Fenntartott CSV oszlopnév/nevek: {columns}. Ezek a nevek le vannak foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}), és nem hozhatók létre új attribútumokként.",
"invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.",
"no_activity_yet": "Még nincs tevékenység",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth",
"attribute_key_required": "キーは必須です",
"attribute_key_reserved_future_default": "このキーは将来のデフォルト属性用に予約されています({reservedKeys})。別のキーを選択してください。",
"attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります",
"attribute_label": "ラベル",
"attribute_label_placeholder": "例: 生年月日",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
"invalid_csv_reserved_column_names": "予約されたCSV列名: {columns}。これらの名前は将来のデフォルト属性({reservedKeys})用に予約されており、新しい属性として作成できません。",
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
"no_activity_yet": "まだアクティビティがありません",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",
"attribute_key_required": "Sleutel is verplicht",
"attribute_key_reserved_future_default": "Sleutel is gereserveerd voor toekomstige standaardattributen ({reservedKeys}). Kies een andere sleutel.",
"attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter",
"attribute_label": "Label",
"attribute_label_placeholder": "bijv. Geboortedatum",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Persoonlijke link genereren",
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
"invalid_csv_reserved_column_names": "Gereserveerde CSV-kolomnaam/namen: {columns}. Deze namen zijn gereserveerd voor toekomstige standaardattributen ({reservedKeys}) en kunnen niet als nieuwe attributen worden aangemaakt.",
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
"no_activity_yet": "Nog geen activiteit",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolha uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex: Data de nascimento",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Esses nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
"no_activity_yet": "Nenhuma atividade ainda",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolhe uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex. Data de nascimento",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Estes nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
"no_activity_yet": "Ainda sem atividade",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",
"attribute_key_required": "Cheia este obligatorie",
"attribute_key_reserved_future_default": "Cheia este rezervată pentru atribute implicite viitoare ({reservedKeys}). Te rugăm să alegi o cheie diferită.",
"attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă",
"attribute_label": "Etichetă",
"attribute_label_placeholder": "ex: Data nașterii",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generează link personal",
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
"invalid_csv_reserved_column_names": "Nume de coloană CSV rezervate: {columns}. Aceste nume sunt rezervate pentru atribute implicite viitoare ({reservedKeys}) și nu pot fi create ca atribute noi.",
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
"no_activity_yet": "Nicio activitate încă",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth",
"attribute_key_required": "Ключ обязателен",
"attribute_key_reserved_future_default": "Ключ зарезервирован для будущих атрибутов по умолчанию ({reservedKeys}). Пожалуйста, выбери другой ключ.",
"attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы",
"attribute_label": "Метка",
"attribute_label_placeholder": "например, дата рождения",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Сгенерировать персональную ссылку",
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
"invalid_csv_reserved_column_names": "Зарезервированные названия столбцов CSV: {columns}. Эти названия зарезервированы для будущих атрибутов по умолчанию ({reservedKeys}) и не могут быть созданы как новые атрибуты.",
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
"no_activity_yet": "Пока нет активности",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",
"attribute_key_required": "Nyckel krävs",
"attribute_key_reserved_future_default": "Nyckeln är reserverad för framtida standardattribut ({reservedKeys}). Välj en annan nyckel.",
"attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav",
"attribute_label": "Etikett",
"attribute_label_placeholder": "t.ex. Födelsedatum",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Generera personlig länk",
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
"invalid_csv_reserved_column_names": "Reserverade CSV-kolumnnamn: {columns}. Dessa namn är reserverade för framtida standardattribut ({reservedKeys}) och kan inte skapas som nya attribut.",
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
"no_activity_yet": "Ingen aktivitet än",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "Yalnızca küçük harfler, rakamlar ve alt çizgiler. Bir harfle başlamalıdır.",
"attribute_key_placeholder": "örn. dogum_tarihi",
"attribute_key_required": "Anahtar gereklidir",
"attribute_key_reserved_future_default": "Anahtar, gelecekteki varsayılan özellikler için ayrılmıştır ({reservedKeys}). Lütfen farklı bir anahtar seçin.",
"attribute_key_safe_identifier_required": "Anahtar güvenli bir tanımlayıcı olmalıdır: yalnızca küçük harfler, rakamlar ve alt çizgiler içermeli ve bir harfle başlamalıdır",
"attribute_label": "Etiket",
"attribute_label_placeholder": "örn. Doğum Tarihi",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "Kişisel Bağlantı Oluştur",
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir anket seç.",
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harf, rakam ve alt çizgi içerebilir ve bir harfle başlamalıdır.",
"invalid_csv_reserved_column_names": "Ayrılmış CSV sütun adı/adları: {columns}. Bu adlar gelecekteki varsayılan özellikler ({reservedKeys}) için ayrılmıştır ve yeni özellik olarak oluşturulamaz.",
"invalid_date_format": "Geçersiz tarih formatı. Lütfen geçerli bir tarih kullanın.",
"invalid_number_format": "Geçersiz sayı formatı. Lütfen geçerli bir sayı girin.",
"no_activity_yet": "Henüz aktivite yok",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "键为必填项",
"attribute_key_reserved_future_default": "该键已保留用于未来的默认属性({reservedKeys})。请选择其他键。",
"attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头",
"attribute_label": "标签",
"attribute_label_placeholder": "例如:出生日期",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
"invalid_csv_reserved_column_names": "CSV 列名已被保留:{columns}。这些名称已保留用于未来的默认属性({reservedKeys}),无法创建为新属性。",
"invalid_date_format": "日期格式无效。请使用有效日期。",
"invalid_number_format": "数字格式无效。请输入有效的数字。",
"no_activity_yet": "暂无活动",
@@ -2612,6 +2610,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 +2818,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 -2
View File
@@ -1935,7 +1935,6 @@
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "金鑰為必填項目",
"attribute_key_reserved_future_default": "此鍵已保留供未來預設屬性使用({reservedKeys})。請選擇其他鍵。",
"attribute_key_safe_identifier_required": "金鑰必須為安全識別字:僅限小寫字母、數字和底線,且必須以字母開頭",
"attribute_label": "標籤",
"attribute_label_placeholder": "例如:出生日期",
@@ -1970,7 +1969,6 @@
"generate_personal_link": "產生個人連結",
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
"invalid_csv_reserved_column_names": "保留的 CSV 欄位名稱:{columns}。這些名稱已保留供未來預設屬性使用({reservedKeys}),無法建立為新屬性。",
"invalid_date_format": "日期格式無效。請使用有效的日期。",
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
"no_activity_yet": "尚無活動",
@@ -2612,6 +2610,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 +2818,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 智慧工具。",
@@ -10,10 +10,6 @@ import {
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceIds: string[], params: TGetContactAttributeKeysFilter) => {
@@ -49,13 +45,6 @@ export const createContactAttributeKey = async (
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { workspaceId, name, description, key, dataType } = contactAttributeKey;
if (isReservedFutureDefaultAttributeKey(key)) {
return err({
type: "bad_request",
details: [{ field: "key", issue: getReservedFutureDefaultAttributeKeyIssue([key]) }],
});
}
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
workspace: {
@@ -105,28 +105,6 @@ describe("createContactAttributeKey", () => {
}
});
test("returns bad request when key is reserved for future defaults", async () => {
const result = await createContactAttributeKey({
...inputContactAttributeKey,
key: "user_id",
});
expect(result.ok).toBe(false);
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "bad_request",
details: [
{
field: "key",
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
],
});
}
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
@@ -2,10 +2,6 @@ import { z } from "zod";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({})
.refine(
@@ -42,14 +38,6 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyInput",
@@ -77,14 +65,6 @@ export const ZContactAttributeKeyCreateInput = ZContactAttributeKey.pick({
path: ["key"],
});
}
if (isReservedFutureDefaultAttributeKey(data.key)) {
ctx.addIssue({
code: "custom",
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyCreateInput",
+1 -36
View File
@@ -2,7 +2,7 @@ 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 { InvalidInputError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
import { createUser, getUser, getUserByEmail, updateUser, updateUserLastLoginAt } from "./user";
@@ -53,41 +53,6 @@ describe("User Management", () => {
expect(result).toEqual(mockPrismaUser);
});
test("creates a user with an Azure AD enterprise display name", async () => {
const enterpriseDisplayName = "Lastname,Firstname (DEPT) COMPANY-CITY";
vi.mocked(prisma.user.create).mockResolvedValueOnce({
...mockPrismaUser,
name: enterpriseDisplayName,
});
const result = await createUser({
email: mockUser.email,
name: enterpriseDisplayName,
locale: mockUser.locale,
});
expect(result.name).toBe(enterpriseDisplayName);
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: enterpriseDisplayName,
}),
})
);
});
test("rejects display names with newline characters", async () => {
await expect(
createUser({
email: mockUser.email,
name: "Lastname,Firstname\n(DEPT) COMPANY-CITY",
locale: mockUser.locale,
})
).rejects.toThrow(ValidationError);
expect(prisma.user.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError when email already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
@@ -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),
});
@@ -3,7 +3,6 @@ import cubejs, { type Query } from "@cubejs-client/core";
import { randomUUID } from "node:crypto";
import { logger } from "@formbricks/logger";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "@/modules/ee/analysis/lib/date-presets";
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { type TCubeQuerySource, getCubeApiConfig } from "./cube-config";
@@ -90,7 +89,7 @@ export async function executeTenantScopedQuery(input: TScopedCubeQueryInput) {
try {
const client = cubejs(token, { apiUrl });
const resultSet = await client.load(expandPresetDateRanges(input.query) as Query);
const resultSet = await client.load(input.query as Query);
const result = resultSet.tablePivot();
queueCubeQueryAuditEvent({ input, requestId, status: "success" });
return result;
@@ -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,
@@ -83,24 +83,6 @@ export function TimeDimensionPanel({
}
};
const handleDateRangeTypeChange = (value: "preset" | "custom") => {
setDateRangeType(value);
if (!timeDimension) return;
if (value === "preset") {
const nextPreset = presetValue || "last 30 days";
if (!presetValue) setPresetValue(nextPreset);
onTimeDimensionChange({ ...timeDimension, dateRange: nextPreset });
return;
}
const start = customStartDate ?? new Date();
const end = customEndDate ?? start;
if (!customStartDate) setCustomStartDate(start);
if (!customEndDate) setCustomEndDate(end);
onTimeDimensionChange({ ...timeDimension, dateRange: [start, end] });
};
if (!timeDimension) {
return (
<div className="space-y-2">
@@ -168,7 +150,7 @@ export function TimeDimensionPanel({
<div className="space-y-2">
<Select
value={dateRangeType}
onValueChange={(value) => handleDateRangeTypeChange(value as "preset" | "custom")}>
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
@@ -1,96 +0,0 @@
import { describe, expect, test } from "vitest";
import type { TChartQuery } from "@formbricks/types/analysis";
import { expandPresetDateRanges } from "./date-presets";
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
});
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
describe("expandPresetDateRanges", () => {
test("includes today for 'last 7 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
});
test("includes today for 'last 30 days'", () => {
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
});
test("expands 'today' to today..today", () => {
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
});
test("expands 'yesterday' to yesterday..yesterday", () => {
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
});
test("'this month' runs from the 1st through today", () => {
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
});
test("'last month' is the full previous calendar month", () => {
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
});
test("'last month' handles year rollover", () => {
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
});
test("'this quarter' starts at the first day of the calendar quarter", () => {
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
});
test("'this year' starts on Jan 1", () => {
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
});
test("leaves explicit [start, end] tuple unchanged", () => {
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
});
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
});
test("returns input unchanged when there are no time dimensions", () => {
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
});
test("preserves other timeDimension fields (granularity, dimension)", () => {
const q: TChartQuery = {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
],
};
const result = expandPresetDateRanges(q, NOW);
expect(result.timeDimensions?.[0]).toMatchObject({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: ["2026-05-15", "2026-05-21"],
});
});
test("does not mutate the input query", () => {
const q = queryWithDateRange("last 7 days");
const before = JSON.stringify(q);
expandPresetDateRanges(q, NOW);
expect(JSON.stringify(q)).toBe(before);
});
});
@@ -1,37 +0,0 @@
import { addDays, formatDate, startOfDay, startOfMonth, startOfQuarter, startOfYear } from "date-fns";
import type { TChartQuery } from "@formbricks/types/analysis";
// Cube's native "last N days" / "this month" / etc. strings exclude today; we expand them
// to explicit inclusive ranges so charts behave like every other analytics tool (GA, Mixpanel,
// PostHog, ...) and include the current partial day.
const PRESET_RESOLVERS: Record<string, (now: Date) => [Date, Date]> = {
today: (now) => [startOfDay(now), startOfDay(now)],
yesterday: (now) => [addDays(startOfDay(now), -1), addDays(startOfDay(now), -1)],
"last 7 days": (now) => [addDays(startOfDay(now), -6), startOfDay(now)],
"last 30 days": (now) => [addDays(startOfDay(now), -29), startOfDay(now)],
"this month": (now) => [startOfMonth(now), startOfDay(now)],
"last month": (now) => {
const firstOfThisMonth = startOfMonth(now);
const lastOfLastMonth = addDays(firstOfThisMonth, -1);
return [startOfMonth(lastOfLastMonth), lastOfLastMonth];
},
"this quarter": (now) => [startOfQuarter(now), startOfDay(now)],
"this year": (now) => [startOfYear(now), startOfDay(now)],
};
export const expandPresetDateRanges = (query: TChartQuery, now: Date = new Date()): TChartQuery => {
if (!query.timeDimensions?.length) return query;
const expanded = query.timeDimensions.map((td) => {
if (typeof td.dateRange !== "string") return td;
const resolver = PRESET_RESOLVERS[td.dateRange.toLowerCase().trim()];
if (!resolver) return td;
const [start, end] = resolver(now);
return {
...td,
dateRange: [formatDate(start, "yyyy-MM-dd"), formatDate(end, "yyyy-MM-dd")] as [string, string],
};
});
return { ...query, timeDimensions: expanded };
};
@@ -3,12 +3,8 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import {
TContactAttributeKeyUpdateInput,
ZContactAttributeKeyUpdateInput,
@@ -60,10 +56,6 @@ export const updateContactAttributeKey = async (
): Promise<TContactAttributeKey | null> => {
validateInputs([contactAttributeKeyId, ZId], [data, ZContactAttributeKeyUpdateInput]);
if (data.key && isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
try {
const contactAttributeKey = await prisma.contactAttributeKey.update({
where: {
@@ -1,21 +1,12 @@
import { z } from "zod";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const ZContactAttributeKeyCreateInput = z.object({
key: z
.string()
.refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
}),
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
description: z.string().optional(),
type: z.enum(["custom"]),
dataType: ZContactAttributeDataType.optional(),
@@ -33,9 +24,6 @@ export const ZContactAttributeKeyUpdateInput = z.object({
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
})
.optional(),
dataType: ZContactAttributeDataType.optional(),
});
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { createContactAttributeKey, getContactAttributeKeys } from "./contact-attribute-keys";
@@ -144,17 +144,6 @@ describe("createContactAttributeKey", () => {
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when key is reserved for future defaults", async () => {
await expect(
createContactAttributeKey(workspaceId, {
...createInput,
key: "user_id",
})
).rejects.toThrow(InvalidInputError);
expect(prisma.contactAttributeKey.count).not.toHaveBeenCalled();
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("should throw DatabaseError if Prisma create fails", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
const errorMessage = "Prisma create error";
@@ -3,14 +3,10 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceIds: string[]): Promise<TContactAttributeKey[]> => {
@@ -33,10 +29,6 @@ export const createContactAttributeKey = async (
workspaceId: string,
data: TContactAttributeKeyCreateInput
): Promise<TContactAttributeKey | null> => {
if (isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
where: {
workspaceId,
@@ -6,10 +6,6 @@ import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-k
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
@@ -549,22 +545,6 @@ export const upsertBulkContacts = async (
});
}
const reservedNewKeys = attributeKeys.filter(
(key) => !existingKeySet.has(key) && isReservedFutureDefaultAttributeKey(key)
);
if (reservedNewKeys.length > 0) {
return err({
type: "bad_request",
details: [
{
field: "attributes",
issue: getReservedFutureDefaultAttributeKeyIssue(reservedNewKeys),
},
],
});
}
// Type Detection Phase
const attributeValuesByKey = buildAttributeValuesByKey(contacts);
const attributeTypeMap = determineAttributeTypes(attributeValuesByKey, existingAttributeKeys);
@@ -347,42 +347,6 @@ describe("upsertBulkContacts", () => {
expect(prisma.$executeRaw).toHaveBeenCalled();
});
test("should return bad request when payload creates reserved future default keys", async () => {
const mockContacts = [
{
attributes: [
{ attributeKey: { key: "email", name: "Email" }, value: "john@example.com" },
{ attributeKey: { key: "user_id", name: "User Id" }, value: "user-123" },
],
},
];
const mockParsedEmails = ["john@example.com"];
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce([
{ id: "attr-key-email", key: "email", workspaceId: mockWorkspaceId, name: "Email" },
] as any);
const result = await upsertBulkContacts(mockContacts, mockWorkspaceId, mockParsedEmails);
expect(result.ok).toBe(false);
expect(prisma.contact.createMany).not.toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "bad_request",
details: [
{
field: "attributes",
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
],
});
}
});
test("should update attribute key names when they change", async () => {
// Mock data: a contact with an attribute that has a new name for an existing key
const mockContacts = [
@@ -10,10 +10,6 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import {
createContactAttributeKey,
deleteContactAttributeKey,
@@ -23,15 +19,10 @@ import {
const ZCreateContactAttributeKeyAction = z.object({
workspaceId: ZId,
key: z
.string()
.refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.refine((val) => !isReservedFutureDefaultAttributeKey(val), {
error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE,
}),
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
name: z.string().optional(),
description: z.string().optional(),
dataType: ZContactAttributeDataType.optional(),
@@ -8,10 +8,6 @@ import { useTranslation } from "react-i18next";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase, isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -97,14 +93,6 @@ export function CreateAttributeModal({ workspaceId }: Readonly<CreateAttributeMo
);
return false;
}
if (isReservedFutureDefaultAttributeKey(key)) {
setKeyError(
t("workspace.contacts.attribute_key_reserved_future_default", {
reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
})
);
return false;
}
setKeyError("");
return true;
};
@@ -10,37 +10,33 @@ export const CsvTable = ({ data }: CsvTableProps) => {
}
const columns = Object.keys(data[0]);
return (
<div className="w-full overflow-x-auto rounded-md">
<table className="w-max min-w-full border-separate border-spacing-0 text-left text-xs">
<thead>
<tr className="bg-slate-100">
{columns.map((header) => (
<th
key={header}
scope="col"
className="sticky top-0 z-10 min-w-[120px] border-b-2 border-slate-200 bg-slate-100 px-3 py-2 font-semibold">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="bg-white">
{columns.map((header) => (
<td
key={`${rowIndex}-${header}`}
className="min-w-[120px] border-b border-slate-200 px-3 py-2">
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
{row[header] ?? ""}
</span>
</td>
))}
</tr>
<div
className="sticky top-0 z-10 grid gap-2 border-b-2 border-slate-100 bg-slate-100 px-3 py-2 text-left"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, index) => (
<span
key={index}
className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold capitalize leading-tight">
{header.replace(/_/g, " ")}
</span>
))}
</div>
{data.map((row, rowIndex) => (
<div
key={rowIndex}
className="grid gap-2 border-b border-gray-200 bg-white px-3 py-2 text-left leading-tight last:border-b-0"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, colIndex) => (
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-xs">
{row[header]}
</span>
))}
</tbody>
</table>
</div>
))}
</div>
);
};
@@ -4,10 +4,6 @@ import { ChevronDownIcon } from "lucide-react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { Button } from "@/modules/ui/components/button";
import {
Command,
@@ -45,8 +41,6 @@ export const UploadContactsAttributeCombobox = ({
currentKey,
}: ITagsComboboxProps) => {
const { t } = useTranslation();
const normalizedSearchValue = searchValue.trim();
useEffect(() => {
// reset search value and value when closing the combobox
if (!open) {
@@ -56,56 +50,20 @@ export const UploadContactsAttributeCombobox = ({
// Check if the search value is a valid safe identifier for creating new attributes
const isValidNewKey = useMemo(() => {
if (!normalizedSearchValue) return false;
return isSafeIdentifier(normalizedSearchValue);
}, [normalizedSearchValue]);
const isReservedNewKey = useMemo(() => {
if (!normalizedSearchValue) return false;
return isReservedFutureDefaultAttributeKey(normalizedSearchValue);
}, [normalizedSearchValue]);
if (!searchValue) return false;
return isSafeIdentifier(searchValue.trim());
}, [searchValue]);
const existingKeyMatch = useMemo(() => {
return keys.find((tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase()));
}, [keys, searchValue]);
const handleCreateKey = () => {
if (isValidNewKey && !existingKeyMatch && !isReservedNewKey) {
createKey(normalizedSearchValue);
if (isValidNewKey && !existingKeyMatch) {
createKey(searchValue.trim());
}
};
const renderCreateOptionContent = () => {
if (isValidNewKey && !isReservedNewKey) {
return (
<button
onClick={handleCreateKey}
className="h-8 w-full text-left hover:cursor-pointer hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!!existingKeyMatch}>
+ {t("common.add")} {normalizedSearchValue}
</button>
);
}
if (isReservedNewKey) {
return (
<div className="flex flex-col py-1 text-xs text-slate-500">
<span className="text-red-500">
{t("workspace.contacts.attribute_key_reserved_future_default", {
reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
})}
</span>
</div>
);
}
return (
<div className="flex flex-col py-1 text-xs text-slate-500">
<span className="text-red-500">{t("workspace.contacts.attribute_key_safe_identifier_required")}</span>
</div>
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -177,7 +135,22 @@ export const UploadContactsAttributeCombobox = ({
);
})}
{searchValue !== "" && !keys.some((tag) => tag.label === searchValue) && (
<CommandItem value="_create">{renderCreateOptionContent()}</CommandItem>
<CommandItem value="_create">
{isValidNewKey ? (
<button
onClick={handleCreateKey}
className="h-8 w-full text-left hover:cursor-pointer hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!!existingKeyMatch}>
+ Add {searchValue}
</button>
) : (
<div className="flex flex-col py-1 text-xs text-slate-500">
<span className="text-red-500">
{t("workspace.contacts.attribute_key_safe_identifier_required")}
</span>
</div>
)}
</CommandItem>
)}
</CommandGroup>
</CommandList>
@@ -13,10 +13,6 @@ import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
import { CsvTable } from "@/modules/ee/contacts/components/csv-table";
import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute";
import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -261,6 +257,7 @@ export const UploadContactsCSVButton = ({
useEffect(() => {
const matches: Record<string, string> = {};
const invalidColumns: string[] = [];
for (const columnName of csvColumns) {
let matched = false;
@@ -273,66 +270,25 @@ export const UploadContactsCSVButton = ({
}
if (!matched) {
// This column will become a new attribute - validate it's a safe identifier
if (!isSafeIdentifier(columnName)) {
invalidColumns.push(columnName);
}
matches[columnName] = columnName;
}
}
setAttributeMap(matches);
}, [contactAttributeKeys, csvColumns]);
useEffect(() => {
if (!csvColumns.length || !csvResponse.length) {
return;
}
const invalidColumns: string[] = [];
const reservedColumns: string[] = [];
for (const columnName of csvColumns) {
const mappedAttribute = attributeMap[columnName];
if (!mappedAttribute) {
continue;
}
const mapsToExistingAttribute = contactAttributeKeys.some(
(attributeKey) => attributeKey.id === mappedAttribute
);
if (mapsToExistingAttribute) {
continue;
}
if (!isSafeIdentifier(mappedAttribute)) {
invalidColumns.push(columnName);
continue;
}
if (isReservedFutureDefaultAttributeKey(mappedAttribute)) {
reservedColumns.push(columnName);
}
}
const errorMessages: string[] = [];
// Show error for invalid column names that would become new attributes
if (invalidColumns.length > 0) {
errorMessages.push(
setError(
t("workspace.contacts.invalid_csv_column_names", {
columns: invalidColumns.join(", "),
})
);
}
if (reservedColumns.length > 0) {
errorMessages.push(
t("workspace.contacts.invalid_csv_reserved_column_names", {
columns: reservedColumns.join(", "),
reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
})
);
}
setError(errorMessages.join("\n"));
}, [attributeMap, contactAttributeKeys, csvColumns, csvResponse.length, t]);
}, [contactAttributeKeys, csvColumns, t]);
useEffect(() => {
if (error && errorContainerRef.current) {
@@ -348,7 +304,7 @@ export const UploadContactsCSVButton = ({
const exampleData = [
{
email: "user1@example.com",
user_id: "1001",
userId: "1001",
first_name: "John",
last_name: "Doe",
age: "28",
@@ -357,7 +313,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user2@example.com",
user_id: "1002",
userId: "1002",
first_name: "Jane",
last_name: "Smith",
age: "34",
@@ -366,7 +322,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user3@example.com",
user_id: "1003",
userId: "1003",
first_name: "Mark",
last_name: "Jones",
age: "45",
@@ -375,7 +331,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user4@example.com",
user_id: "1004",
userId: "1004",
first_name: "Emily",
last_name: "Brown",
age: "22",
@@ -384,7 +340,7 @@ export const UploadContactsCSVButton = ({
},
{
email: "user5@example.com",
user_id: "1005",
userId: "1005",
first_name: "David",
last_name: "Wilson",
age: "31",
@@ -1,39 +0,0 @@
// Keep these keys reserved until the v5.1 migration moves default contact attributes
// from camelCase to safe identifiers with backward compatibility aliases.
// This is a preventive guardrail only (no schema/data migration in v5).
export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS = [
"user_id",
"first_name",
"last_name",
] as const;
export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT =
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS.join(", ");
export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE = `Key is reserved for the v5.1 safe-identifier default attribute migration. Reserved keys: ${RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT}.`;
const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEY_SET: ReadonlySet<string> = new Set(
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS
);
const normalizeKey = (key: string): string => key.trim().toLowerCase();
export const isReservedFutureDefaultAttributeKey = (key: string): boolean => {
return RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEY_SET.has(normalizeKey(key));
};
export const getReservedFutureDefaultAttributeKeys = (keys: string[]): string[] => {
const normalized = keys
.map(normalizeKey)
.filter((key) => key.length > 0 && isReservedFutureDefaultAttributeKey(key));
return Array.from(new Set(normalized));
};
export const getReservedFutureDefaultAttributeKeyIssue = (keys: string[]): string => {
const reservedKeys = getReservedFutureDefaultAttributeKeys(keys);
return `Reserved attribute key(s): ${reservedKeys.join(
", "
)}. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.`;
};
@@ -221,30 +221,6 @@ describe("updateAttributes", () => {
);
});
test("skips creating reserved future default attributes", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]);
vi.mocked(getContactAttributes).mockResolvedValue({ email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const result = await updateAttributes(contactId, userId, workspaceId, {
email: "john@example.com",
user_id: "future-safe",
});
expect(result.success).toBe(true);
expect(result.errors).toContainEqual({
code: "reserved_attribute_keys",
params: {
issue:
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
},
});
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("returns success with only email attribute", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" });
+3 -22
View File
@@ -6,10 +6,6 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { prepareNewSDKAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import {
@@ -42,7 +38,6 @@ const MESSAGE_TEMPLATES: Record<string, string> = {
userid_already_exists: "The userId already exists for this environment and was not updated.",
invalid_attribute_keys:
"Skipped creating attribute(s) with invalid key(s): {keys}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
reserved_attribute_keys: "{issue}",
attribute_limit_exceeded:
"Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
new_attribute_created: "Created new attribute '{key}' with type '{dataType}'",
@@ -309,15 +304,12 @@ export const updateAttributes = async (
// Validate that new attribute keys are safe identifiers
const validNewAttributes: typeof newAttributes = [];
const invalidKeys: string[] = [];
const reservedKeys: string[] = [];
for (const attr of newAttributes) {
if (!isSafeIdentifier(attr.key)) {
invalidKeys.push(attr.key);
} else if (isReservedFutureDefaultAttributeKey(attr.key)) {
reservedKeys.push(attr.key);
} else {
if (isSafeIdentifier(attr.key)) {
validNewAttributes.push(attr);
} else {
invalidKeys.push(attr.key);
}
}
@@ -333,17 +325,6 @@ export const updateAttributes = async (
);
}
if (reservedKeys.length > 0) {
errors.push({
code: "reserved_attribute_keys",
params: { issue: getReservedFutureDefaultAttributeKeyIssue(reservedKeys) },
});
logger.warn(
{ workspaceId, reservedKeys },
"SDK tried to create reserved future default attribute keys - skipping"
);
}
if (validNewAttributes.length > 0) {
const totalAttributeClassesLength = contactAttributeKeys.length + validNewAttributes.length;
@@ -147,13 +147,6 @@ describe("createContactAttributeKey", () => {
await expect(createContactAttributeKey({ workspaceId, key: "email" })).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when key is reserved for future defaults", async () => {
await expect(createContactAttributeKey({ workspaceId, key: "user_id" })).rejects.toThrow(
InvalidInputError
);
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("rethrows unknown prisma error codes", async () => {
const err = Object.assign(new Error("Some prisma error"), { code: PrismaErrorType.RecordDoesNotExist });
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(err);
@@ -4,10 +4,6 @@ import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
import {
getReservedFutureDefaultAttributeKeyIssue,
isReservedFutureDefaultAttributeKey,
} from "./attribute-key-policy";
export const getContactAttributeKeys = reactCache(
async (workspaceId: string): Promise<TContactAttributeKey[]> => {
@@ -35,10 +31,6 @@ export const createContactAttributeKey = async (data: {
description?: string;
dataType?: TContactAttributeDataType;
}): Promise<TContactAttributeKey> => {
if (isReservedFutureDefaultAttributeKey(data.key)) {
throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key]));
}
try {
const contactAttributeKey = await prisma.contactAttributeKey.create({
data: {
@@ -537,22 +537,6 @@ describe("Contacts Lib", () => {
).rejects.toThrow(ValidationError);
});
test("throws ValidationError when CSV creates reserved future default keys", async () => {
const reservedCsvData = [{ email: "john@example.com", user_id: "user-1" }];
const attributeMap = { email: "email", user_id: "user_id" };
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce([
{ key: "email", id: "key-1", dataType: "string" },
] as any);
await expect(
createContactsFromCSV(reservedCsvData as any, mockWorkspaceId, "skip", attributeMap)
).rejects.toThrow(ValidationError);
expect(prisma.contactAttributeKey.createMany).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma error", async () => {
const attributeMap = { email: "email" };
const prismaError = new Prisma.PrismaClientKnownRequestError("DB Error", {
@@ -9,10 +9,6 @@ import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import {
getReservedFutureDefaultAttributeKeyIssue,
getReservedFutureDefaultAttributeKeys,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
@@ -416,11 +412,6 @@ const createMissingAttributeKeys = async (
);
}
const reservedKeys = getReservedFutureDefaultAttributeKeys(missingKeys);
if (reservedKeys.length > 0) {
throw new ValidationError(getReservedFutureDefaultAttributeKeyIssue(reservedKeys));
}
// Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
const uniqueMissingKeys = new Map<string, string>();
for (const key of missingKeys) {
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
@@ -49,7 +49,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
if (surveyWorkspaceId !== parsedInput.workspaceId) {
throw new InvalidInputError("Survey and segment are not in the same workspace");
throw new Error("Survey and segment are not in the same workspace");
}
}
@@ -82,7 +82,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new InvalidInputError(errMsg);
throw new Error(errMsg);
}
const segment = await createSegment(parsedInput);
@@ -139,7 +139,7 @@ export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdate
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new InvalidInputError(errMsg);
throw new Error(errMsg);
}
await checkForRecursiveSegmentFilter(parsedFilters.data, parsedInput.segmentId);
@@ -169,7 +169,7 @@ export const loadNewSegmentAction = authenticatedActionClient
const segmentWorkspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId);
if (surveyWorkspaceId !== segmentWorkspaceId) {
throw new InvalidInputError("Segment and survey are not in the same workspace");
throw new Error("Segment and survey are not in the same workspace");
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
@@ -184,33 +184,22 @@ export function AddFilterModal({
[t]
);
const contactAttributeKeysForPicker = useMemo(() => {
// `userId` is represented by the person filter (fingerprint icon), so hide it from attribute entries.
return contactAttributeKeys.filter((attributeKey) => attributeKey.key !== "userId");
}, [contactAttributeKeys]);
const contactAttributeKeysFiltered = useMemo(() => {
if (!contactAttributeKeysForPicker) return [];
if (!contactAttributeKeys) return [];
if (!searchValue) return contactAttributeKeysForPicker;
if (!searchValue) return contactAttributeKeys;
return contactAttributeKeysForPicker.filter((attributeKey) => {
return contactAttributeKeys.filter((attributeKey) => {
const attributeValueToSeach = attributeKey.name ?? attributeKey.key;
return attributeValueToSeach.toLowerCase().includes(searchValue.toLowerCase());
});
}, [contactAttributeKeysForPicker, searchValue]);
}, [contactAttributeKeys, searchValue]);
const contactAttributeFiltered = useMemo(() => {
const personIdentifiers = [{ id: "userId", label: t("common.user_id") }];
const contactAttributes = [{ name: "userId" }];
return personIdentifiers.filter((personIdentifier) => {
const query = searchValue.toLowerCase();
return (
personIdentifier.id.toLowerCase().includes(query) ||
personIdentifier.label.toLowerCase().includes(query)
);
});
}, [searchValue, t]);
return contactAttributes.filter((ca) => ca.name.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
const segmentsFiltered = useMemo(() => {
if (!segments) return [];
@@ -294,10 +283,10 @@ export function AddFilterModal({
{filters.contactAttributeFiltered.map((personAttribute) => (
<FilterButton
key={personAttribute.id}
data-testid={`filter-btn-person-${personAttribute.id}`}
key={personAttribute.name}
data-testid={`filter-btn-person-${personAttribute.name}`}
icon={<FingerprintIcon className="h-4 w-4" />}
label={personAttribute.label}
label={personAttribute.name}
onClick={() => {
handleAddFilter({
type: "person",
@@ -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,6 +6,8 @@ import { processResponsePipelineJob } from "./process-response-pipeline-job";
const {
mockFetch,
mockCaptureSurveyResponsePostHogEvent,
mockCreatePinnedDispatcher,
mockDispatcherDestroy,
mockGetIntegrations,
mockGetResponseCountBySurveyId,
mockHandleIntegrations,
@@ -21,13 +23,16 @@ const {
mockSendFollowUpsForResponse,
mockSendResponseFinishedEmail,
mockSendTelemetryEvents,
mockValidateWebhookUrl,
mockValidateAndResolveWebhookUrl,
} = 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(),
@@ -43,7 +48,7 @@ const {
mockSendFollowUpsForResponse: vi.fn(),
mockSendResponseFinishedEmail: vi.fn(),
mockSendTelemetryEvents: vi.fn(),
mockValidateWebhookUrl: vi.fn(),
mockValidateAndResolveWebhookUrl: vi.fn(),
};
});
@@ -80,6 +85,7 @@ vi.mock(import("@/lib/constants"), async (importOriginal) => {
return {
...actual,
POSTHOG_KEY: undefined,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
};
});
@@ -104,7 +110,8 @@ vi.mock("./posthog", () => ({
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: mockValidateWebhookUrl,
validateAndResolveWebhookUrl: mockValidateAndResolveWebhookUrl,
createPinnedDispatcher: mockCreatePinnedDispatcher,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
@@ -205,7 +212,9 @@ describe("processResponsePipelineJob", () => {
mockPrismaUserFindMany.mockResolvedValue([]);
mockGetResponseCountBySurveyId.mockResolvedValue(1);
mockHandleIntegrations.mockResolvedValue(undefined);
mockValidateWebhookUrl.mockResolvedValue(undefined);
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "93.184.216.34", family: 4 });
mockDispatcherDestroy.mockResolvedValue(undefined);
mockCreatePinnedDispatcher.mockImplementation(() => ({ destroy: mockDispatcherDestroy }));
mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined);
mockRecordResponseCreatedMeterEvent.mockResolvedValue(undefined);
mockSendResponseFinishedEmail.mockResolvedValue(undefined);
@@ -242,7 +251,7 @@ describe("processResponsePipelineJob", () => {
triggers: { has: "responseCreated" },
},
});
expect(mockValidateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
@@ -481,7 +490,7 @@ describe("processResponsePipelineJob", () => {
url: "https://example.com/webhook",
},
]);
mockValidateWebhookUrl.mockRejectedValue(webhookError);
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
await expect(
processResponsePipelineJob(
@@ -527,7 +536,7 @@ describe("processResponsePipelineJob", () => {
locale: "en",
},
]);
mockValidateWebhookUrl.mockRejectedValue(webhookError);
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
await expect(
processResponsePipelineJob(
@@ -780,6 +789,133 @@ 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 { POSTHOG_KEY } from "@/lib/constants";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { createPinnedDispatcher, validateAndResolveWebhookUrl } 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,9 +136,13 @@ 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: RequestInit,
options: WebhookFetchOptions,
timeoutMs: number = WEBHOOK_TIMEOUT_MS
): Promise<Response> => {
const abortController = new AbortController();
@@ -153,7 +157,7 @@ const fetchWithTimeout = async (
return await fetch(url, {
...options,
signal,
});
} as RequestInit);
} finally {
clearTimeout(timeoutId);
}
@@ -222,15 +226,47 @@ const createWebhookDeliveryTask = async ({
);
}
await validateWebhookUrl(webhook.url);
const response = await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
});
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";
if (!response.ok) {
throw new Error(`Webhook delivery failed with status ${response.status}`);
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"
);
}
}
} 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;
@@ -26,21 +26,17 @@ export const RichTextTranslationInput = ({
}: RichTextTranslationInputProps) => {
const [firstRender, setFirstRender] = useState(true);
const [editorKey, setEditorKey] = useState(0);
// Separates external value changes (e.g. AI fill) from the editor's own write-back so we
// only remount for the former.
const lastWrittenRef = useRef(value);
// Suppresses Lexical's mount-time empty listener fire which would otherwise clobber an
// externally-applied value back to "".
const initialContentSetRef = useRef(false);
const prevDisabledRef = useRef(disabled);
// Remount the editor when AI translation finishes (disabled transitions from true → false)
// so the editor picks up the externally populated value.
useEffect(() => {
if (value !== lastWrittenRef.current) {
lastWrittenRef.current = value;
initialContentSetRef.current = false;
if (prevDisabledRef.current && !disabled) {
setEditorKey((k) => k + 1);
setFirstRender(true);
}
}, [value]);
prevDisabledRef.current = disabled;
}, [disabled]);
return (
<div className={disabled ? "cursor-not-allowed rounded-md opacity-60" : "rounded-md"}>
@@ -51,12 +47,7 @@ export const RichTextTranslationInput = ({
firstRender={firstRender}
setFirstRender={setFirstRender}
getText={() => md.render(value)}
setText={(v: string) => {
if (!initialContentSetRef.current && v === "") return;
initialContentSetRef.current = true;
lastWrittenRef.current = v;
onChange(path, v);
}}
setText={(v: string) => onChange(path, v)}
localSurvey={localSurvey}
elementId={elementId}
selectedLanguageCode={languageCode}
+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,
@@ -9,8 +9,6 @@ import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formb
import { validateInputs } from "@/lib/utils/validate";
import { deleteFilesByWorkspaceId } from "@/modules/storage/service";
// Keep v5 defaults aligned with current production camelCase keys.
// Safe-identifier migration (with backwards compatibility) is intentionally deferred to v5.1.
const DEFAULT_CONTACT_ATTRIBUTE_KEYS: Prisma.ContactAttributeKeyCreateWithoutWorkspaceInput[] = [
{
key: "userId",
@@ -153,7 +151,7 @@ export const deleteWorkspace = async (workspaceId: string): Promise<TWorkspace>
if (workspace) {
const s3Result = await deleteFilesByWorkspaceId(workspaceId, []);
if (!s3Result.ok && "error" in s3Result) {
if (!s3Result.ok) {
// fail silently because we don't want to throw an error if the files are not deleted
logger.error(s3Result.error, "Error deleting S3 files");
}
@@ -1,3 +0,0 @@
-- Remove isAIDataAnalysisEnabled — feature flag added prematurely before any AI data analysis
-- feature existed. Will be reintroduced at the directory level when the feature is built.
ALTER TABLE "Organization" DROP COLUMN IF EXISTS "isAIDataAnalysisEnabled";
+15 -13
View File
@@ -648,21 +648,23 @@ model Workspace {
/// @property billing - JSON field containing billing information
/// @property whitelabel - Whitelabel configuration for the organization
/// @property isAISmartToolsEnabled - Controls access to AI smart tools (e.g. translations) that never touch collected data
/// @property isAIDataAnalysisEnabled - Controls access to AI data analysis features that touch experience data
model Organization {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
memberships Membership[]
workspaces Workspace[]
billing OrganizationBilling?
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
memberships Membership[]
workspaces Workspace[]
billing OrganizationBilling?
/// [OrganizationWhitelabel]
whitelabel Json @default("{}")
invites Invite[]
isAISmartToolsEnabled Boolean @default(false)
teams Team[]
apiKeys ApiKey[]
feedbackDirectories FeedbackDirectory[]
whitelabel Json @default("{}")
invites Invite[]
isAISmartToolsEnabled Boolean @default(false)
isAIDataAnalysisEnabled Boolean @default(false)
teams Team[]
apiKeys ApiKey[]
feedbackDirectories FeedbackDirectory[]
}
/// Stores billing and Stripe synchronization data for an organization.
-2
View File
@@ -449,8 +449,6 @@ async function main(): Promise<void> {
},
});
// Keep seed defaults aligned with production v5 camelCase keys.
// Safe-identifier migration is deferred to v5.1.
// Contact attribute keys for the workspace
const defaultAttributeKeys = [
{ name: "Email", key: "email", isUnique: true, type: "default" as const },
+1
View File
@@ -69,4 +69,5 @@ export const ZOrganization = z.object({
name: z.string(),
whitelabel: ZOrganizationWhiteLabel,
isAISmartToolsEnabled: z.boolean().default(false) as z.ZodType<Organization["isAISmartToolsEnabled"]>,
isAIDataAnalysisEnabled: z.boolean().default(false) as z.ZodType<Organization["isAIDataAnalysisEnabled"]>,
}) satisfies z.ZodType<Organization>;
+1
View File
@@ -135,6 +135,7 @@ export const exampleData = {
},
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
} as unknown as TOrganization,
},
@@ -1049,39 +1049,20 @@ export function Survey({
switch (errorType) {
case TResponseErrorCodesEnum.ResponseSendingError:
return (
<>
{localSurvey.type !== "link" ? (
<div className="bg-survey-bg relative h-8 w-full">
<div className="flex w-full items-center justify-end">
<SurveyCloseButton
onClose={onClose}
hoverColor={styling.inputBgColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8}
/>
</div>
</div>
) : null}
<ResponseErrorComponent
responseData={responseQueue?.getUnsentData() ?? responseData}
questions={questions}
onRetry={retryResponse}
isRetrying={isRetrying}
/>
</>
<ResponseErrorComponent
responseData={responseQueue?.getUnsentData() ?? responseData}
questions={questions}
onRetry={retryResponse}
isRetrying={isRetrying}
/>
);
case TResponseErrorCodesEnum.RecaptchaError:
case TResponseErrorCodesEnum.InvalidDeviceError:
return (
<>
{localSurvey.type !== "link" ? (
<div className="bg-survey-bg relative h-8 w-full">
<div className="flex w-full items-center justify-end">
<SurveyCloseButton
onClose={onClose}
hoverColor={styling.inputBgColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8}
/>
</div>
<div className="bg-survey-bg flex h-6 justify-end pt-2 pr-2">
<SurveyCloseButton onClose={onClose} />
</div>
) : null}
<ErrorComponent errorType={errorType} />
+2
View File
@@ -86,6 +86,7 @@ export const ZOrganization = z.object({
whitelabel: ZOrganizationWhitelabel.optional(),
billing: ZOrganizationBilling,
isAISmartToolsEnabled: z.boolean().prefault(false),
isAIDataAnalysisEnabled: z.boolean().prefault(false),
});
export const ZOrganizationCreateInput = z.object({
@@ -100,6 +101,7 @@ export const ZOrganizationUpdateInput = z.object({
whitelabel: ZOrganizationWhitelabel.optional(),
billing: ZOrganizationBilling.optional(),
isAISmartToolsEnabled: z.boolean().optional(),
isAIDataAnalysisEnabled: z.boolean().optional(),
});
export type TOrganizationUpdateInput = z.infer<typeof ZOrganizationUpdateInput>;
+1 -1
View File
@@ -31,7 +31,7 @@ export const ZUserName = z
.min(1, {
error: "Name should be at least 1 character long",
})
.regex(/^[\p{L}\p{M} ',()\d-]+$/u, "Invalid name format");
.regex(/^[\p{L}\p{M}\s'\d-]+$/u, "Invalid name format");
export const ZUserEmail = z
.email({