Compare commits

...

9 Commits

Author SHA1 Message Date
Dhruwang
c092f005b6 validation poc 2026-01-06 14:51:30 +05:30
Dhruwang Jariwala
25266e4566 fix: disappearing survey preview (#7065) 2026-01-06 06:23:11 +00:00
Matti Nannt
b960cfd2a1 chore: harden CSP and X-Frame-Options headers (#7062)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 06:21:19 +00:00
Matti Nannt
9e1d1c1dc2 feat: implement robust database seeding strategy (#7017)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-05 15:58:58 +00:00
Matti Nannt
8c63a9f7af chore: remove debug log from next.config.mjs (#7063) 2026-01-05 15:52:04 +00:00
Anshuman Pandey
fff0a7f052 fix: fixes duplicate userId issue with the contacts UI (#7051)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-05 09:21:50 +00:00
Anshuman Pandey
0ecc8aabff fix: fixes single use multi lang surveyUrl issue (#7057) 2026-01-05 06:08:15 +00:00
Dhruwang Jariwala
01cc0ab64d fix: correct typo in recontact waiting time description and adjust da… (#7056) 2026-01-05 06:02:28 +00:00
Anshuman Pandey
1d125bdac2 fix: fixes user api attribute override error (#7050) 2026-01-05 05:55:22 +00:00
48 changed files with 2950 additions and 539 deletions

View File

@@ -2,7 +2,7 @@
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -34,7 +34,6 @@ export const AnonymousLinksTab = ({
locale,
isReadOnly,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
const { t } = useTranslation();
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
pendingAction: () => Promise<void> | void;
} | null>(null);
const surveyUrlWithCustomSuid = useMemo(() => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", "CUSTOM-ID");
return url.toString();
}, [surveyUrl]);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
@@ -177,7 +182,11 @@ export const AnonymousLinksTab = ({
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
const surveyLinks = singleUseIds.map((singleUseId) => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", singleUseId);
return url.toString();
});
// Create content with just the links
const csvContent = surveyLinks.join("\n");

View File

@@ -4835,7 +4835,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
segment: null,
blocks: [
{
id: createId(),
id: "cltxxaa6x0000g8hacxdxeje1",
name: "Block 1",
elements: [
{
@@ -4857,7 +4857,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
},
{
id: createId(),
id: "cltxxaa6x0000g8hacxdxeje2",
name: "Block 2",
elements: [
{

View File

@@ -1826,7 +1826,7 @@ checksums:
environments/workspace/general/delete_workspace_settings_description: 411ef100f167fc8fca64e833b6c0d030
environments/workspace/general/error_saving_workspace_information: e7b8022785619ef34de1fb1630b3c476
environments/workspace/general/only_owners_or_managers_can_delete_workspaces: 58da180cd2610210302d85a9896d80bd
environments/workspace/general/recontact_waiting_time: 8977b5160fbf88c456608982b33e246f
environments/workspace/general/recontact_waiting_time: 6873c18d51830e2cadef67cce6a2c95c
environments/workspace/general/recontact_waiting_time_settings_description: ebd64fddbea9387b12c027a18358db7e
environments/workspace/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
environments/workspace/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6

View File

@@ -1588,6 +1588,8 @@
"upload_at_least_2_images": "Upload at least 2 images",
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"validation_rules": "Validation rules",
"validation_rules_description": "Only accept responses that meet the following criteria",
"url_not_supported": "URL not supported",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
@@ -1935,7 +1937,7 @@
"delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.",
"error_saving_workspace_information": "Error saving workspace information",
"only_owners_or_managers_can_delete_workspaces": "Only owners or managers can delete workspaces",
"recontact_waiting_time": "Cooldown Period (scross surveys)",
"recontact_waiting_time": "Cooldown Period (across surveys)",
"recontact_waiting_time_settings_description": "Control how frequently users can be surveyed across all Website & App Surveys in this workspace.",
"this_action_cannot_be_undone": "This action cannot be undone.",
"wait_x_days_before_showing_next_survey": "Wait X days before showing next survey:",

View File

@@ -91,6 +91,13 @@ export const EditContactAttributesModal = ({
return allKeyOptions.filter((option) => !selectedKeys.has(String(option.value)));
};
// Reset form when modal closes
useEffect(() => {
if (!open) {
form.reset(defaultValues);
}
}, [open, defaultValues, form]);
// Scroll to first error on validation failure
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {

View File

@@ -2,7 +2,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import {
getContactAttributes,
hasEmailAttribute,
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
import { updateAttributes } from "./attributes";
vi.mock("@/lib/constants", () => ({
@@ -20,6 +24,7 @@ vi.mock("@/modules/ee/contacts/lib/contact-attributes", async () => {
...actual,
getContactAttributes: vi.fn(),
hasEmailAttribute: vi.fn(),
hasUserIdAttribute: vi.fn(),
};
});
vi.mock("@formbricks/database", () => ({
@@ -75,6 +80,7 @@ describe("updateAttributes", () => {
vi.clearAllMocks();
// Set default mock return values - these will be overridden in individual tests
vi.mocked(getContactAttributes).mockResolvedValue({});
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
});
@@ -83,19 +89,21 @@ describe("updateAttributes", () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", 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 attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toBeUndefined();
});
test("skips updating email if it already exists", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "john@example.com" };
@@ -106,45 +114,147 @@ describe("updateAttributes", () => {
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
});
test("creates new attributes if under limit", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0]]);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane" });
test("skips updating userId if it already exists", async () => {
const attributeKeysWithUserId: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "old-user-id" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", newAttr: "val" };
const attributes = { name: "John", userId: "duplicate-user-id" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
expect(result.ignoreUserIdAttribute).toBe(true);
});
test("skips updating both email and userId if both already exist", async () => {
const attributeKeysWithUserId: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
vi.mocked(getContactAttributes).mockResolvedValue({
name: "Jane",
email: "old@example.com",
userId: "old-user-id",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "duplicate@example.com", userId: "duplicate-user-id" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
expect(result.ignoreEmailAttribute).toBe(true);
expect(result.ignoreUserIdAttribute).toBe(true);
});
test("creates new attributes if under limit", async () => {
// Use name and email keys (2 existing keys), MAX is mocked to 2
// We update existing attributes, no new ones created
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0], attributeKeys[1]]); // name, email
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", 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 attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toBeUndefined();
});
test("does not create new attributes if over the limit", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", 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 attributes = { name: "John", newAttr: "val" };
// Include email to satisfy the "at least one of email or userId" requirement
const attributes = { name: "John", email: "john@example.com", newAttr: "val" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
});
test("returns success with no attributes to update or create", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([]);
vi.mocked(getContactAttributes).mockResolvedValue({});
test("returns success with only email attribute", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@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 attributes = {};
const attributes = { email: "updated@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toBeUndefined();
});
test("deletes non-default attributes that are removed from payload", async () => {
test("deletes non-default attributes when deleteRemovedAttributes is true", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({
name: "Jane",
email: "jane@example.com",
customAttr: "oldValue",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
const attributes = { name: "John", email: "john@example.com" };
// Pass deleteRemovedAttributes: true to enable deletion behavior
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
where: {
contactId,
attributeKeyId: {
in: ["key-3"],
},
},
});
expect(result.success).toBe(true);
expect(result.messages).toBeUndefined();
});
test("does not delete attributes when deleteRemovedAttributes is false (default behavior)", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
@@ -156,27 +266,19 @@ describe("updateAttributes", () => {
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
const attributes = { name: "John", email: "john@example.com" };
// Default behavior (deleteRemovedAttributes: false) should NOT delete existing attributes
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
where: {
contactId,
attributeKeyId: {
in: ["key-3"],
},
},
});
// deleteMany should NOT be called since we're merging, not replacing
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.messages).toBeUndefined();
});
test("does not delete default attributes even if removed from payload", async () => {
test("does not delete default attributes even when deleteRemovedAttributes is true", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
// Need to include userId and firstName in attributeKeys for this test
// Note: DEFAULT_ATTRIBUTES includes: email, userId, firstName, lastName (not "name")
const attributeKeysWithDefaults: TContactAttributeKey[] = [
{
@@ -231,13 +333,105 @@ describe("updateAttributes", () => {
firstName: "John",
});
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 attributes = { customAttr: "value" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Pass deleteRemovedAttributes: true to test that default attributes are still preserved
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
// Should not delete default attributes (email, userId, firstName) - deleteMany should not be called
// since all current attributes are default attributes
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
expect(result.success).toBe(true);
});
test("preserves existing email when empty string is submitted", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "existing@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 });
// Attempt to clear email by submitting empty string
const attributes = { name: "John", email: "" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Verify that the transaction was called with the preserved email
expect(prisma.$transaction).toHaveBeenCalled();
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
// The email should be preserved (existing@example.com), not cleared
expect(transactionCall).toHaveLength(2); // name and email
expect(result.success).toBe(true);
});
test("allows clearing userId when empty string is submitted", async () => {
const attributeKeysWithUserId: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "existing-user-id" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
// Clear userId by submitting empty string - this should be allowed
const attributes = { name: "John", userId: "" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Verify that the transaction was called
expect(prisma.$transaction).toHaveBeenCalled();
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
// Only name and userId (empty) should be in the transaction
expect(transactionCall).toHaveLength(2); // name and userId (with empty value)
expect(result.success).toBe(true);
});
test("preserves existing values when both email and userId would be cleared", async () => {
const attributeKeysWithBoth: TContactAttributeKey[] = [
...attributeKeys,
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: true,
name: "User ID",
description: null,
type: "default",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithBoth);
vi.mocked(getContactAttributes).mockResolvedValue({
name: "Jane",
email: "existing@example.com",
userId: "existing-user-id",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
// Attempt to clear both email and userId
const attributes = { name: "John", email: "", userId: "" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages).toContain(
"Either email or userId is required. The existing values were preserved."
);
});
});

View File

@@ -5,7 +5,11 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import {
getContactAttributes,
hasEmailAttribute,
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
@@ -47,12 +51,28 @@ const deleteAttributes = async (
};
};
/**
* Updates or creates contact attributes.
*
* @param contactId - The ID of the contact to update
* @param userId - The user ID of the contact
* @param environmentId - The environment ID
* @param contactAttributesParam - The attributes to update/create
* @param deleteRemovedAttributes - When true, deletes attributes that exist in DB but are not in the payload.
* Use this for UI forms where all attributes are submitted. Default is false (merge behavior) for API calls.
*/
export const updateAttributes = async (
contactId: string,
userId: string,
environmentId: string,
contactAttributesParam: TContactAttributes
): Promise<{ success: boolean; messages?: string[]; ignoreEmailAttribute?: boolean }> => {
contactAttributesParam: TContactAttributes,
deleteRemovedAttributes: boolean = false
): Promise<{
success: boolean;
messages?: string[];
ignoreEmailAttribute?: boolean;
ignoreUserIdAttribute?: boolean;
}> => {
validateInputs(
[contactId, ZId],
[userId, ZString],
@@ -61,23 +81,89 @@ export const updateAttributes = async (
);
let ignoreEmailAttribute = false;
let ignoreUserIdAttribute = false;
const messages: string[] = [];
// Fetch current attributes, contact attribute keys, and email check in parallel
const [currentAttributes, contactAttributeKeys, existingEmailAttribute] = await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
contactAttributesParam.email
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
: Promise.resolve(null),
]);
// Fetch current attributes, contact attribute keys, and email/userId checks in parallel
const [currentAttributes, contactAttributeKeys, existingEmailAttribute, existingUserIdAttribute] =
await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
contactAttributesParam.email
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
: Promise.resolve(null),
contactAttributesParam.userId
? hasUserIdAttribute(contactAttributesParam.userId, environmentId, contactId)
: Promise.resolve(null),
]);
// Process email existence early
const { email, ...remainingAttributes } = contactAttributesParam;
const contactAttributes = existingEmailAttribute ? remainingAttributes : contactAttributesParam;
// Process email and userId existence early
const emailExists = !!existingEmailAttribute;
const userIdExists = !!existingUserIdAttribute;
// Delete attributes that were removed (using the deleteAttributes service)
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
// Remove email and/or userId from attributes if they already exist on another contact
let contactAttributes = { ...contactAttributesParam };
// Determine what the final email and userId values will be after this update
// Only consider a value as "submitted" if it was explicitly included in the attributes
const emailWasSubmitted = "email" in contactAttributesParam;
const userIdWasSubmitted = "userId" in contactAttributesParam;
const submittedEmail = emailWasSubmitted ? contactAttributes.email?.trim() || "" : null;
const submittedUserId = userIdWasSubmitted ? contactAttributes.userId?.trim() || "" : null;
const currentEmail = currentAttributes.email || "";
const currentUserId = currentAttributes.userId || "";
// Calculate final values:
// - If not submitted, keep current value
// - If submitted but duplicate exists, keep current value
// - If submitted and no duplicate, use submitted value
const getFinalEmail = (): string => {
if (submittedEmail === null) return currentEmail;
if (emailExists) return currentEmail;
return submittedEmail;
};
const getFinalUserId = (): string => {
if (submittedUserId === null) return currentUserId;
if (userIdExists) return currentUserId;
return submittedUserId;
};
const finalEmail = getFinalEmail();
const finalUserId = getFinalUserId();
// Ensure at least one of email or userId will have a value after update
if (!finalEmail && !finalUserId) {
// If both would be empty, preserve the current values
if (currentEmail) {
contactAttributes.email = currentEmail;
}
if (currentUserId) {
contactAttributes.userId = currentUserId;
}
messages.push("Either email or userId is required. The existing values were preserved.");
}
if (emailExists) {
const { email: _email, ...rest } = contactAttributes;
contactAttributes = rest;
ignoreEmailAttribute = true;
}
if (userIdExists) {
const { userId: _userId, ...rest } = contactAttributes;
contactAttributes = rest;
ignoreUserIdAttribute = true;
}
// Delete attributes that were removed (only when explicitly requested)
// This is used by UI forms where all attributes are submitted
// For API calls, we want merge behavior by default (only update passed attributes)
if (deleteRemovedAttributes) {
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
}
// Create lookup map for attribute keys
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
@@ -99,12 +185,12 @@ export const updateAttributes = async (
}
);
let messages: string[] = emailExists
? ["The email already exists for this environment and was not updated."]
: [];
if (emailExists) {
ignoreEmailAttribute = true;
messages.push("The email already exists for this environment and was not updated.");
}
if (userIdExists) {
messages.push("The userId already exists for this environment and was not updated.");
}
// Update all existing attributes
@@ -159,7 +245,8 @@ export const updateAttributes = async (
return {
success: true,
messages,
messages: messages.length > 0 ? messages : undefined,
ignoreEmailAttribute,
ignoreUserIdAttribute,
};
};

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactAttributes, hasEmailAttribute } from "./contact-attributes";
import { TContactAttribute } from "@formbricks/types/contact-attribute";
import { getContactAttributes, hasEmailAttribute, hasUserIdAttribute } from "./contact-attributes";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -16,11 +17,12 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const contactId = "contact-1";
const environmentId = "env-1";
const email = "john@example.com";
const userId = "user-123";
const mockAttributes = [
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
{ value: "John", attributeKey: { key: "name", name: "Name" } },
];
] as unknown as TContactAttribute[];
describe("getContactAttributes", () => {
beforeEach(() => {
@@ -50,7 +52,9 @@ describe("hasEmailAttribute", () => {
});
test("returns true if email attribute exists", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({ id: "attr-1" });
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
id: "attr-1",
} as unknown as TContactAttribute);
const result = await hasEmailAttribute(email, environmentId, contactId);
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
where: {
@@ -67,3 +71,29 @@ describe("hasEmailAttribute", () => {
expect(result).toBe(false);
});
});
describe("hasUserIdAttribute", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns true if userId attribute exists on another contact", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
id: "attr-1",
} as unknown as TContactAttribute);
const result = await hasUserIdAttribute(userId, environmentId, contactId);
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
where: {
AND: [{ attributeKey: { key: "userId", environmentId }, value: userId }, { NOT: { contactId } }],
},
select: { id: true },
});
expect(result).toBe(true);
});
test("returns false if userId attribute does not exist on another contact", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue(null);
const result = await hasUserIdAttribute(userId, environmentId, contactId);
expect(result).toBe(false);
});
});

View File

@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
@@ -68,3 +68,31 @@ export const hasEmailAttribute = reactCache(
return !!contactAttribute;
}
);
export const hasUserIdAttribute = reactCache(
async (userId: string, environmentId: string, contactId: string): Promise<boolean> => {
validateInputs([userId, ZString], [environmentId, ZId], [contactId, ZId]);
const contactAttribute = await prisma.contactAttribute.findFirst({
where: {
AND: [
{
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
{
NOT: {
contactId,
},
},
],
},
select: { id: true },
});
return !!contactAttribute;
}
);

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { updateAttributes } from "./attributes";
import { getContactAttributeKeys } from "./contact-attribute-keys";
import { getContactAttributes } from "./contact-attributes";
@@ -16,7 +16,7 @@ describe("updateContactAttributes", () => {
vi.clearAllMocks();
});
it("should update contact attributes successfully", async () => {
test("should update contact attributes with deleteRemovedAttributes: true", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
@@ -91,13 +91,14 @@ describe("updateContactAttributes", () => {
expect(getContact).toHaveBeenCalledWith(contactId);
expect(getContactAttributeKeys).toHaveBeenCalledWith(environmentId);
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes);
// Should call updateAttributes with deleteRemovedAttributes: true for UI form updates
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes, true);
expect(getContactAttributes).toHaveBeenCalledWith(contactId);
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
expect(result.updatedAttributeKeys).toBeUndefined();
});
it("should detect new attribute keys when created", async () => {
test("should detect new attribute keys when created", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
@@ -184,7 +185,7 @@ describe("updateContactAttributes", () => {
]);
});
it("should handle missing userId with warning message", async () => {
test("should handle missing userId gracefully", async () => {
const contactId = "contact123";
const environmentId = "env123";
const attributes = {
@@ -226,13 +227,13 @@ describe("updateContactAttributes", () => {
const result = await updateContactAttributes(contactId, attributes);
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes);
expect(result.messages).toContain(
"Warning: userId attribute is missing. Some operations may not work correctly."
);
// When userId is not in attributes, pass empty string to updateAttributes
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes, true);
// No warning message - the backend now gracefully handles missing userId by keeping current value
expect(result.messages).toBeUndefined();
});
it("should merge messages from updateAttributes", async () => {
test("should merge messages from updateAttributes", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
@@ -279,7 +280,7 @@ describe("updateContactAttributes", () => {
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
});
it("should throw error if contact not found", async () => {
test("should throw error if contact not found", async () => {
const contactId = "contact123";
const attributes = {
firstName: "John",

View File

@@ -13,11 +13,6 @@ export interface UpdateContactAttributesResult {
updatedAttributeKeys?: TContactAttributeKey[];
}
/**
* Updates contact attributes for a single contact.
* Handles loading contact data, extracting userId, calling updateAttributes,
* and detecting if new attribute keys were created.
*/
export const updateContactAttributes = async (
contactId: string,
attributes: TContactAttributes
@@ -35,16 +30,13 @@ export const updateContactAttributes = async (
const userId = attributes.userId ?? "";
const messages: string[] = [];
if (!attributes.userId) {
messages.push("Warning: userId attribute is missing. Some operations may not work correctly.");
}
// Get current attribute keys before update to detect new ones
const currentAttributeKeys = await getContactAttributeKeys(environmentId);
const currentKeysSet = new Set(currentAttributeKeys.map((key) => key.key));
// Call the existing updateAttributes function
const updateResult = await updateAttributes(contactId, userId, environmentId, attributes);
// Call updateAttributes with deleteRemovedAttributes: true
// UI forms submit all attributes, so any missing attribute should be deleted
const updateResult = await updateAttributes(contactId, userId, environmentId, attributes, true);
// Merge any messages from updateAttributes
if (updateResult.messages) {

View File

@@ -3,7 +3,7 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactWithAttributes, TTransformPersonInput } from "@/modules/ee/contacts/types/contact";
export const getContactIdentifier = (contactAttributes: TContactAttributes | null): string => {
return contactAttributes?.email ?? contactAttributes?.userId ?? "";
return contactAttributes?.email || contactAttributes?.userId || "";
};
export const convertPrismaContactAttributes = (

View File

@@ -335,7 +335,34 @@ export const ZEditContactAttributesForm = z.object({
}
});
// Validate email format if key is "email"
// Check that at least one of email or userId has a value
const emailAttr = attributes.find((attr) => attr.key === "email");
const userIdAttr = attributes.find((attr) => attr.key === "userId");
const hasEmail = emailAttr?.value && emailAttr.value.trim() !== "";
const hasUserId = userIdAttr?.value && userIdAttr.value.trim() !== "";
if (!hasEmail && !hasUserId) {
// Find the indices to show errors on the relevant fields
const emailIndex = attributes.findIndex((attr) => attr.key === "email");
const userIdIndex = attributes.findIndex((attr) => attr.key === "userId");
// When both are empty, show "Either email or userId is required" on both fields
if (emailIndex !== -1 && userIdIndex !== -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either email or userId is required",
path: [emailIndex, "value"],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either email or userId is required",
path: [userIdIndex, "value"],
});
}
}
// Validate email format if key is "email" and has a value
attributes.forEach((attr, index) => {
if (attr.key === "email" && attr.value && attr.value.trim() !== "") {
const emailResult = z.string().email().safeParse(attr.value);

View File

@@ -11,12 +11,14 @@ import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
@@ -398,6 +400,19 @@ export const MultipleChoiceElementForm = ({
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
/>
{/* Validation Rules Editor - only for MultipleChoiceMulti */}
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceMulti}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRule[]) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
)}
</form>
);
};

View File

@@ -4,11 +4,17 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
import { JSX, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyOpenTextElement, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
import {
TSurveyElementTypeEnum,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
} from "@formbricks/types/surveys/elements";
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -245,6 +251,17 @@ export const OpenElementForm = ({
customContainerClass="p-0"
/>
</div>
{/* Validation Rules Editor */}
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.OpenText}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRule[]) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</div>
</form>
);

View File

@@ -0,0 +1,341 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
APPLICABLE_RULES,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface ValidationRulesEditorProps {
elementType: TSurveyElementTypeEnum;
validationRules: TValidationRule[];
onUpdateRules: (rules: TValidationRule[]) => void;
}
// Rule type definitions with labels and whether they need a value input
const RULE_TYPE_CONFIG: Record<
TValidationRuleType,
{
label: string;
needsValue: boolean;
valueType?: "number" | "text";
valuePlaceholder?: string;
unitOptions?: { value: string; label: string }[];
}
> = {
required: {
label: "Is not empty",
needsValue: false,
},
minLength: {
label: "Is longer than",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
unitOptions: [
{ value: "characters", label: "characters" },
],
},
maxLength: {
label: "Is shorter than",
needsValue: true,
valueType: "number",
valuePlaceholder: "500",
unitOptions: [
{ value: "characters", label: "characters" },
],
},
pattern: {
label: "Matches pattern",
needsValue: true,
valueType: "text",
valuePlaceholder: "^[A-Z].*",
},
email: {
label: "Is valid email",
needsValue: false,
},
url: {
label: "Is valid URL",
needsValue: false,
},
phone: {
label: "Is valid phone",
needsValue: false,
},
minValue: {
label: "Is greater than",
needsValue: true,
valueType: "number",
valuePlaceholder: "0",
},
maxValue: {
label: "Is less than",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
},
minSelections: {
label: "At least",
needsValue: true,
valueType: "number",
valuePlaceholder: "1",
unitOptions: [{ value: "options", label: "options selected" }],
},
maxSelections: {
label: "At most",
needsValue: true,
valueType: "number",
valuePlaceholder: "3",
unitOptions: [{ value: "options", label: "options selected" }],
},
};
// Get available rule types for an element type
const getAvailableRuleTypes = (
elementType: TSurveyElementTypeEnum,
existingRules: TValidationRule[]
): TValidationRuleType[] => {
const elementTypeKey = elementType.toString();
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
// Filter out rules that are already added (for non-repeatable rules)
const existingTypes = new Set(existingRules.map((r) => r.params.type));
return applicable.filter((ruleType) => {
// Allow only one of each rule type
return !existingTypes.has(ruleType);
});
};
// Get the value from rule params based on rule type
const getRuleValue = (rule: TValidationRule): number | string | undefined => {
const params = rule.params as Record<string, unknown>;
if ("min" in params) return params.min as number;
if ("max" in params) return params.max as number;
if ("pattern" in params) return params.pattern as string;
return undefined;
};
// Create params object from rule type and value
const createRuleParams = (
ruleType: TValidationRuleType,
value?: number | string
): TValidationRule["params"] => {
switch (ruleType) {
case "required":
return { type: "required" };
case "minLength":
return { type: "minLength", min: Number(value) || 0 };
case "maxLength":
return { type: "maxLength", max: Number(value) || 100 };
case "pattern":
return { type: "pattern", pattern: String(value) || "" };
case "email":
return { type: "email" };
case "url":
return { type: "url" };
case "phone":
return { type: "phone" };
case "minValue":
return { type: "minValue", min: Number(value) || 0 };
case "maxValue":
return { type: "maxValue", max: Number(value) || 100 };
case "minSelections":
return { type: "minSelections", min: Number(value) || 1 };
case "maxSelections":
return { type: "maxSelections", max: Number(value) || 3 };
default:
return { type: "required" };
}
};
export const ValidationRulesEditor = ({
elementType,
validationRules,
onUpdateRules,
}: ValidationRulesEditorProps) => {
const { t } = useTranslation();
const isEnabled = validationRules.length > 0;
const handleEnable = () => {
const availableRules = getAvailableRuleTypes(elementType, []);
if (availableRules.length > 0) {
const defaultRuleType = availableRules[0];
const newRule: TValidationRule = {
id: createId(),
params: createRuleParams(defaultRuleType),
enabled: true,
};
onUpdateRules([newRule]);
}
};
const handleDisable = () => {
onUpdateRules([]);
};
const handleToggle = (checked: boolean) => {
if (checked) {
handleEnable();
} else {
handleDisable();
}
};
const handleAddRule = () => {
const availableRules = getAvailableRuleTypes(elementType, validationRules);
if (availableRules.length === 0) return;
const newRuleType = availableRules[0];
const newRule: TValidationRule = {
id: createId(),
params: createRuleParams(newRuleType),
enabled: true,
};
onUpdateRules([...validationRules, newRule]);
};
const handleDeleteRule = (ruleId: string) => {
const updated = validationRules.filter((r) => r.id !== ruleId);
onUpdateRules(updated);
};
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
return {
...rule,
params: createRuleParams(newType),
};
});
onUpdateRules(updated);
};
const handleRuleValueChange = (ruleId: string, value: string) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
const ruleType = rule.params.type;
const config = RULE_TYPE_CONFIG[ruleType];
const parsedValue = config.valueType === "number" ? Number(value) || 0 : value;
return {
...rule,
params: createRuleParams(ruleType, parsedValue),
};
});
onUpdateRules(updated);
};
const availableRulesForAdd = getAvailableRuleTypes(elementType, validationRules);
const canAddMore = availableRulesForAdd.length > 0;
return (
<AdvancedOptionToggle
isChecked={isEnabled}
onToggle={handleToggle}
htmlId="validation-rules-toggle"
title={t("environments.surveys.edit.validation_rules")}
description={t("environments.surveys.edit.validation_rules_description")}
customContainerClass="p-0 mt-4"
childrenContainerClass="flex-col p-3 gap-2">
{validationRules.map((rule, index) => {
const ruleType = rule.params.type;
const config = RULE_TYPE_CONFIG[ruleType];
const currentValue = getRuleValue(rule);
// Get available types for this rule (current type + unused types, no duplicates)
const otherAvailableTypes = getAvailableRuleTypes(
elementType,
validationRules.filter((r) => r.id !== rule.id)
).filter((t) => t !== ruleType);
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
return (
<div key={rule.id} className="flex w-full items-center gap-2">
{/* Rule Type Selector */}
<Select value={ruleType} onValueChange={(value) => handleRuleTypeChange(rule.id, value as TValidationRuleType)}>
<SelectTrigger className={config.needsValue ? "w-[160px]" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTypesForSelect.map((type) => (
<SelectItem key={type} value={type}>
{RULE_TYPE_CONFIG[type].label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value Input (if needed) */}
{config.needsValue && (
<>
<Input
type={config.valueType === "number" ? "number" : "text"}
value={currentValue ?? ""}
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
placeholder={config.valuePlaceholder}
className="w-[80px] bg-white"
min={config.valueType === "number" ? 0 : undefined}
/>
{/* Unit selector (if applicable) */}
{config.unitOptions && config.unitOptions.length > 0 && (
<Select value={config.unitOptions[0].value} disabled>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.unitOptions.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</>
)}
{/* Delete button */}
<Button
variant="secondary"
size="icon"
type="button"
onClick={() => handleDeleteRule(rule.id)}
className="shrink-0">
<TrashIcon className="h-4 w-4" />
</Button>
{/* Add button (only on last row and if can add more) */}
{index === validationRules.length - 1 && canAddMore && (
<Button
variant="secondary"
size="icon"
type="button"
onClick={handleAddRule}
className="shrink-0">
<PlusIcon className="h-4 w-4" />
</Button>
)}
</div>
);
})}
</AdvancedOptionToggle>
);
};

View File

@@ -133,15 +133,32 @@ const nextConfig = {
const isProduction = process.env.NODE_ENV === "production";
const scriptSrcUnsafeEval = isProduction ? "" : " 'unsafe-eval'";
const cspBase = `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`;
return [
{
// Apply X-Frame-Options to all routes except those starting with /s/ or /c/
// Apply X-Frame-Options and restricted frame-ancestors to all routes except those starting with /s/ or /c/
source: "/((?!s/|c/).*)",
headers: [
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "Content-Security-Policy",
value: `${cspBase}; frame-ancestors 'self'`,
},
],
},
{
// Allow surveys (/s/*) and contact survey links (/c/*) to be embedded in iframes on any domain
// Note: These routes need frame-ancestors * to support embedding surveys in customer websites
source: "/(s|c)/:path*",
headers: [
{
key: "Content-Security-Policy",
value: `${cspBase}; frame-ancestors *`,
},
],
},
{
@@ -179,10 +196,6 @@ const nextConfig = {
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
@@ -458,7 +471,5 @@ const sentryOptions = {
// Runtime Sentry reporting still depends on DSN being set via environment variables
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
console.log("BASE PATH", nextConfig.basePath);
export default exportConfig;

View File

@@ -18,6 +18,8 @@
"db:migrate:deploy": "turbo run db:migrate:deploy",
"db:start": "turbo run db:start",
"db:push": "turbo run db:push",
"db:seed": "turbo run db:seed",
"db:seed:clear": "turbo run db:seed -- -- --clear",
"db:up": "docker compose -f docker-compose.dev.yml up -d",
"db:down": "docker compose -f docker-compose.dev.yml down",
"go": "pnpm db:up && turbo run go --concurrency 20",

View File

@@ -82,6 +82,12 @@ Run these commands from the root directory of the Formbricks monorepo:
- Generates new `migration.sql` in the custom directory
- Copies migration to Prisma's internal directory
- Applies all pending migrations to the database
- **`pnpm db:seed`**: Seed the database with sample data
- Upserts base infrastructure (Organization, Project, Environments)
- Creates multi-role users (Admin, Manager)
- Generates complex surveys and sample responses
- **`pnpm db:seed:clear`**: Clear all seeded data and re-seed
- **WARNING**: This will delete existing data in the database.
### Package Level Commands
@@ -92,6 +98,8 @@ Run these commands from the `packages/database` directory:
- Creates new subdirectory with appropriate timestamp
- Generates `migration.ts` file with pre-configured ID and name
- **Note**: Only use Prisma raw queries in data migrations for better performance and to avoid type errors
- **`pnpm db:seed`**: Run the seeding script
- **`pnpm db:seed:clear`**: Clear data and run the seeding script
### Available Scripts
@@ -102,13 +110,41 @@ Run these commands from the `packages/database` directory:
"db:migrate:deploy": "Apply migrations in production",
"db:migrate:dev": "Apply migrations in development",
"db:push": "prisma db push --accept-data-loss",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
"db:seed": "Seed the database with sample data",
"db:seed:clear": "Clear all data and re-seed",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
"dev": "vite build --watch",
"generate": "prisma generate",
"generate-data-migration": "Create new data migration"
}
```
## Database Seeding
The seeding system provides a quick way to set up a functional environment for development, QA, and testing.
### Safety Guard
To prevent accidental data loss in production, seeding is blocked if `NODE_ENV=production`. If you explicitly need to seed a production-like environment (e.g., staging), you must set:
```bash
ALLOW_SEED=true
```
### Seeding Logic
The `pnpm db:seed` script:
1. **Infrastructure**: Upserts a default organization, project, and environments.
2. **Users**: Creates default users with the following credentials (passwords are hashed):
- **Admin**: `admin@formbricks.com` / `password123`
- **Manager**: `manager@formbricks.com` / `password123`
3. **Surveys**: Creates complex sample surveys (Kitchen Sink, CSAT, Draft, etc.) in the **Production** environment.
4. **Responses**: Generates ~50 realistic responses and displays for each survey.
### Idempotency
By default, the seed script uses `upsert` to ensure it can be run multiple times without creating duplicate infrastructure. To perform a clean reset, use `pnpm db:seed:clear`.
## Migration Workflow
### Adding a Schema Migration

View File

@@ -22,6 +22,9 @@
},
"./zod/*": {
"import": "./zod/*.ts"
},
"./seed/constants": {
"import": "./src/seed/constants.ts"
}
},
"scripts": {
@@ -33,7 +36,9 @@
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" node ./dist/scripts/create-saml-database.js",
"db:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
"db:push": "prisma db push --accept-data-loss",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
"db:seed:clear": "dotenv -e ../../.env -- tsx src/seed.ts --clear",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
"db:start": "pnpm db:setup",
"format": "prisma format",
"generate": "prisma generate",
@@ -45,17 +50,20 @@
"@formbricks/logger": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.14.0",
"bcryptjs": "2.4.3",
"zod": "3.24.4",
"zod-openapi": "4.2.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@types/bcryptjs": "2.4.6",
"dotenv-cli": "8.0.0",
"glob": "11.1.0",
"prisma": "6.14.0",
"prisma-json-types-generator": "3.5.4",
"ts-node": "10.9.2",
"tsx": "4.19.2",
"vite": "6.4.1",
"vite-plugin-dts": "4.5.3"
}

View File

@@ -0,0 +1,596 @@
import { createId } from "@paralleldrive/cuid2";
import { type Prisma, PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
import { logger } from "@formbricks/logger";
import { SEED_CREDENTIALS, SEED_IDS } from "./seed/constants";
const prisma = new PrismaClient();
const isProduction = process.env.NODE_ENV === "production";
const allowSeed = process.env.ALLOW_SEED === "true";
if (isProduction && !allowSeed) {
logger.error("ERROR: Seeding blocked in production. Set ALLOW_SEED=true to override.");
process.exit(1);
}
const clearData = process.argv.includes("--clear");
// Define local types to avoid resolution issues in seed script
type SurveyElementType =
| "openText"
| "multipleChoiceSingle"
| "multipleChoiceMulti"
| "nps"
| "cta"
| "rating"
| "consent"
| "date"
| "matrix"
| "address"
| "ranking"
| "contactInfo";
interface SurveyQuestion {
id: string;
type: SurveyElementType;
headline: { default: string; [key: string]: string };
subheader?: { default: string; [key: string]: string };
required?: boolean;
placeholder?: { default: string; [key: string]: string };
longAnswer?: boolean;
choices?: { id: string; label: { default: string }; imageUrl?: string }[];
lowerLabel?: { default: string };
upperLabel?: { default: string };
buttonLabel?: { default: string };
buttonUrl?: string;
buttonExternal?: boolean;
dismissButtonLabel?: { default: string };
ctaButtonLabel?: { default: string };
scale?: string;
range?: number;
label?: { default: string };
allowMulti?: boolean;
format?: string;
rows?: { id: string; label: { default: string } }[];
columns?: { id: string; label: { default: string } }[];
addressLine1?: { show: boolean; required: boolean; placeholder: { default: string } };
addressLine2?: { show: boolean; required: boolean; placeholder: { default: string } };
city?: { show: boolean; required: boolean; placeholder: { default: string } };
state?: { show: boolean; required: boolean; placeholder: { default: string } };
zip?: { show: boolean; required: boolean; placeholder: { default: string } };
country?: { show: boolean; required: boolean; placeholder: { default: string } };
firstName?: { show: boolean; required: boolean; placeholder: { default: string } };
lastName?: { show: boolean; required: boolean; placeholder: { default: string } };
email?: { show: boolean; required: boolean; placeholder: { default: string } };
phone?: { show: boolean; required: boolean; placeholder: { default: string } };
company?: { show: boolean; required: boolean; placeholder: { default: string } };
allowMultipleFiles?: boolean;
maxSizeInMB?: number;
}
async function deleteData(): Promise<void> {
logger.info("Clearing existing data...");
const deleteOrder: Prisma.TypeMap["meta"]["modelProps"][] = [
"responseQuotaLink",
"surveyQuota",
"tagsOnResponses",
"tag",
"surveyFollowUp",
"response",
"display",
"surveyTrigger",
"surveyAttributeFilter",
"surveyLanguage",
"survey",
"actionClass",
"contactAttribute",
"contactAttributeKey",
"contact",
"apiKeyEnvironment",
"apiKey",
"segment",
"webhook",
"integration",
"projectTeam",
"teamUser",
"team",
"project",
"invite",
"membership",
"account",
"user",
"organization",
];
for (const model of deleteOrder) {
try {
// @ts-expect-error - prisma[model] is not typed correctly
await prisma[model].deleteMany();
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error(`Could not delete data from ${model}: ${errorMessage}`);
}
}
logger.info("Data cleared.");
}
const KITCHEN_SINK_QUESTIONS: SurveyQuestion[] = [
{
id: createId(),
type: "openText",
headline: { default: "What do you think of Formbricks?" },
subheader: { default: "Please be honest!" },
required: true,
placeholder: { default: "Your feedback here..." },
longAnswer: true,
},
{
id: createId(),
type: "multipleChoiceSingle",
headline: { default: "How often do you use Formbricks?" },
required: true,
choices: [
{ id: createId(), label: { default: "Daily" } },
{ id: createId(), label: { default: "Weekly" } },
{ id: createId(), label: { default: "Monthly" } },
{ id: createId(), label: { default: "Rarely" } },
],
},
{
id: createId(),
type: "multipleChoiceMulti",
headline: { default: "Which features do you use?" },
required: false,
choices: [
{ id: createId(), label: { default: "Surveys" } },
{ id: createId(), label: { default: "Analytics" } },
{ id: createId(), label: { default: "Integrations" } },
{ id: createId(), label: { default: "Action Tracking" } },
],
},
{
id: createId(),
type: "nps",
headline: { default: "How likely are you to recommend Formbricks?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: createId(),
type: "cta",
headline: { default: "Check out our documentation!" },
required: true,
ctaButtonLabel: { default: "Go to Docs" },
buttonUrl: "https://formbricks.com/docs",
buttonExternal: true,
},
{
id: createId(),
type: "rating",
headline: { default: "Rate your overall experience" },
required: true,
scale: "star",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
},
{
id: createId(),
type: "consent",
headline: { default: "Do you agree to our terms?" },
required: true,
label: { default: "I agree to the terms and conditions" },
},
{
id: createId(),
type: "date",
headline: { default: "When did you start using Formbricks?" },
required: true,
format: "M-d-y",
},
{
id: createId(),
type: "matrix",
headline: { default: "How do you feel about these aspects?" },
required: true,
rows: [
{ id: createId(), label: { default: "UI Design" } },
{ id: createId(), label: { default: "Performance" } },
{ id: createId(), label: { default: "Documentation" } },
],
columns: [
{ id: createId(), label: { default: "Disappointed" } },
{ id: createId(), label: { default: "Neutral" } },
{ id: createId(), label: { default: "Satisfied" } },
],
},
{
id: createId(),
type: "address",
headline: { default: "Where are you located?" },
required: true,
addressLine1: { show: true, required: true, placeholder: { default: "Address Line 1" } },
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
city: { show: true, required: true, placeholder: { default: "City" } },
state: { show: true, required: true, placeholder: { default: "State" } },
zip: { show: true, required: true, placeholder: { default: "Zip" } },
country: { show: true, required: true, placeholder: { default: "Country" } },
},
{
id: createId(),
type: "ranking",
headline: { default: "Rank these features" },
required: true,
choices: [
{ id: createId(), label: { default: "Feature A" } },
{ id: createId(), label: { default: "Feature B" } },
{ id: createId(), label: { default: "Feature C" } },
],
},
{
id: createId(),
type: "contactInfo",
headline: { default: "How can we reach you?" },
required: true,
firstName: { show: true, required: true, placeholder: { default: "First Name" } },
lastName: { show: true, required: true, placeholder: { default: "Last Name" } },
email: { show: true, required: true, placeholder: { default: "Email" } },
phone: { show: true, required: false, placeholder: { default: "Phone" } },
company: { show: true, required: false, placeholder: { default: "Company" } },
},
];
interface SurveyBlock {
id: string;
name: string;
elements: SurveyQuestion[];
}
type ResponseValue = string | number | string[] | Record<string, string>;
const generateQuestionResponse = (q: SurveyQuestion, index: number): ResponseValue | undefined => {
const responseGenerators: Record<SurveyElementType, () => ResponseValue | undefined> = {
openText: () => `Sample response ${String(index)}`,
multipleChoiceSingle: () =>
q.choices ? q.choices[Math.floor(Math.random() * q.choices.length)].label.default : undefined,
multipleChoiceMulti: () =>
q.choices ? [q.choices[0].label.default, q.choices[1].label.default] : undefined,
nps: () => Math.floor(Math.random() * 11),
rating: () => (q.range ? Math.floor(Math.random() * q.range) + 1 : undefined),
cta: () => "clicked",
consent: () => "accepted",
date: () => new Date().toISOString().split("T")[0],
matrix: () => {
const matrixData: Record<string, string> = {};
if (q.rows && q.columns) {
for (const row of q.rows) {
matrixData[row.label.default] =
q.columns[Math.floor(Math.random() * q.columns.length)].label.default;
}
}
return matrixData;
},
ranking: () =>
q.choices ? q.choices.map((c) => c.label.default).sort(() => Math.random() - 0.5) : undefined,
address: () => ({
addressLine1: "Main St 1",
city: "Berlin",
state: "Berlin",
zip: "10115",
country: "Germany",
}),
contactInfo: () => ({
firstName: "John",
lastName: "Doe",
email: `john.doe.${String(index)}@example.com`,
}),
};
return responseGenerators[q.type]();
};
async function generateResponses(surveyId: string, count: number): Promise<void> {
logger.info(`Generating ${String(count)} responses for survey ${surveyId}...`);
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
});
if (!survey) return;
const blocks = survey.blocks as unknown as SurveyBlock[];
const questions = blocks.flatMap((block) => block.elements);
for (let i = 0; i < count; i++) {
const data: Record<string, ResponseValue> = {};
for (const q of questions) {
const response = generateQuestionResponse(q, i);
if (response !== undefined) {
data[q.id] = response;
}
}
await prisma.$transaction(async (tx) => {
const display = await tx.display.create({
data: {
surveyId,
},
});
await tx.response.create({
data: {
surveyId,
finished: true,
// @ts-expect-error - data is not typed correctly
data: data as unknown as Prisma.InputJsonValue,
displayId: display.id,
},
});
});
}
// Generate some displays without responses (e.g., 30% more)
const extraDisplays = Math.floor(count * 0.3);
logger.info(`Generating ${String(extraDisplays)} extra displays for survey ${surveyId}...`);
for (let i = 0; i < extraDisplays; i++) {
await prisma.display.create({
data: {
surveyId,
},
});
}
}
async function main(): Promise<void> {
if (clearData) {
await deleteData();
}
logger.info("Seeding base infrastructure...");
// Organization
const organization = await prisma.organization.upsert({
where: { id: SEED_IDS.ORGANIZATION },
update: {},
create: {
id: SEED_IDS.ORGANIZATION,
name: "Seed Organization",
billing: {
plan: "free",
limits: { projects: 3, monthly: { responses: 1500, miu: 2000 } },
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly",
},
},
});
// Users
const passwordHash = await bcrypt.hash(SEED_CREDENTIALS.ADMIN.password, 10);
await prisma.user.upsert({
where: { id: SEED_IDS.USER_ADMIN },
update: {},
create: {
id: SEED_IDS.USER_ADMIN,
name: "Admin User",
email: SEED_CREDENTIALS.ADMIN.email,
password: passwordHash,
emailVerified: new Date(),
memberships: {
create: {
organizationId: organization.id,
role: "owner",
accepted: true,
},
},
},
});
await prisma.user.upsert({
where: { id: SEED_IDS.USER_MANAGER },
update: {},
create: {
id: SEED_IDS.USER_MANAGER,
name: "Manager User",
email: SEED_CREDENTIALS.MANAGER.email,
password: passwordHash,
emailVerified: new Date(),
memberships: {
create: {
organizationId: organization.id,
role: "manager",
accepted: true,
},
},
},
});
await prisma.user.upsert({
where: { id: SEED_IDS.USER_MEMBER },
update: {},
create: {
id: SEED_IDS.USER_MEMBER,
name: "Member User",
email: SEED_CREDENTIALS.MEMBER.email,
password: passwordHash,
emailVerified: new Date(),
memberships: {
create: {
organizationId: organization.id,
role: "member",
accepted: true,
},
},
},
});
// Project
const project = await prisma.project.upsert({
where: { id: SEED_IDS.PROJECT },
update: {},
create: {
id: SEED_IDS.PROJECT,
name: "Seed Project",
organizationId: organization.id,
},
});
// Environments
await prisma.environment.upsert({
where: { id: SEED_IDS.ENV_DEV },
update: { appSetupCompleted: false },
create: {
id: SEED_IDS.ENV_DEV,
type: "development",
projectId: project.id,
appSetupCompleted: false,
attributeKeys: {
create: [
{ name: "Email", key: "email", isUnique: true, type: "default" },
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" },
],
},
},
});
const prodEnv = await prisma.environment.upsert({
where: { id: SEED_IDS.ENV_PROD },
update: { appSetupCompleted: false },
create: {
id: SEED_IDS.ENV_PROD,
type: "production",
projectId: project.id,
appSetupCompleted: false,
attributeKeys: {
create: [
{ name: "Email", key: "email", isUnique: true, type: "default" },
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" },
],
},
},
});
logger.info("Seeding surveys...");
const createSurveyWithBlocks = async (
id: string,
name: string,
environmentId: string,
status: "inProgress" | "draft" | "completed",
questions: SurveyQuestion[]
): Promise<void> => {
const blocks = [
{
id: createId(),
name: "Main Block",
elements: questions,
},
];
await prisma.survey.upsert({
where: { id },
update: {
environmentId,
type: "link",
// @ts-expect-error - blocks is not typed correctly
blocks: blocks as unknown as Prisma.InputJsonValue[],
},
create: {
id,
name,
environmentId,
status,
type: "link",
// @ts-expect-error - blocks is not typed correctly
blocks: blocks as unknown as Prisma.InputJsonValue[],
},
});
};
// Kitchen Sink Survey
await createSurveyWithBlocks(
SEED_IDS.SURVEY_KITCHEN_SINK,
"Kitchen Sink Survey",
prodEnv.id,
"inProgress",
KITCHEN_SINK_QUESTIONS
);
// CSAT Survey
await createSurveyWithBlocks(SEED_IDS.SURVEY_CSAT, "CSAT Survey", prodEnv.id, "inProgress", [
{
id: createId(),
type: "rating",
headline: { default: "How satisfied are you with our product?" },
required: true,
scale: "smiley",
range: 5,
},
]);
// Draft Survey
await createSurveyWithBlocks(SEED_IDS.SURVEY_DRAFT, "Draft Survey", prodEnv.id, "draft", [
{
id: createId(),
type: "openText",
headline: { default: "Coming soon..." },
required: false,
},
]);
// Completed Survey
await createSurveyWithBlocks(SEED_IDS.SURVEY_COMPLETED, "Exit Survey", prodEnv.id, "completed", [
{
id: createId(),
type: "multipleChoiceSingle",
headline: { default: "Why are you leaving?" },
required: true,
choices: [
{ id: createId(), label: { default: "Too expensive" } },
{ id: createId(), label: { default: "Found a better alternative" } },
{ id: createId(), label: { default: "Missing features" } },
],
},
]);
logger.info("Generating responses...");
await generateResponses(SEED_IDS.SURVEY_KITCHEN_SINK, 50);
await generateResponses(SEED_IDS.SURVEY_CSAT, 50);
await generateResponses(SEED_IDS.SURVEY_COMPLETED, 50);
logger.info(`\n${"=".repeat(50)}`);
logger.info("🚀 SEEDING COMPLETED SUCCESSFULLY");
logger.info("=".repeat(50));
logger.info("\nLog in with the following credentials:");
logger.info(`\n Admin (Owner):`);
logger.info(` Email: ${SEED_CREDENTIALS.ADMIN.email}`);
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
logger.info(`\n Manager:`);
logger.info(` Email: ${SEED_CREDENTIALS.MANAGER.email}`);
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
logger.info(`\n Member:`);
logger.info(` Email: ${SEED_CREDENTIALS.MEMBER.email}`);
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
logger.info(`\n${"=".repeat(50)}\n`);
}
main()
.catch((e: unknown) => {
logger.error(e);
process.exit(1);
})
.finally(() => {
prisma.$disconnect().catch((e: unknown) => {
logger.error(e, "Error disconnecting prisma");
});
});

View File

@@ -0,0 +1,19 @@
export const SEED_IDS = {
USER_ADMIN: "clseedadmin000000000000",
USER_MANAGER: "clseedmanager0000000000",
USER_MEMBER: "clseedmember00000000000",
ORGANIZATION: "clseedorg0000000000000",
PROJECT: "clseedproject000000000",
ENV_DEV: "clseedenvdev0000000000",
ENV_PROD: "clseedenvprod000000000",
SURVEY_KITCHEN_SINK: "clseedsurveykitchen00",
SURVEY_CSAT: "clseedsurveycsat000000",
SURVEY_DRAFT: "clseedsurveydraft00000",
SURVEY_COMPLETED: "clseedsurveycomplete00",
} as const;
export const SEED_CREDENTIALS = {
ADMIN: { email: "admin@formbricks.com", password: "Password#123" },
MANAGER: { email: "manager@formbricks.com", password: "Password#123" },
MEMBER: { email: "member@formbricks.com", password: "Password#123" },
} as const;

View File

@@ -172,7 +172,7 @@ function DateElement({
onSelect={handleDateSelect}
locale={dateLocale}
required={required}
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto h-[stretch] w-full max-w-[25rem] border"
/>
</div>
</div>

View File

@@ -44,8 +44,15 @@
},
"invalid_device_error": {
"message": "Please disable spam protection in the survey settings to continue using this device.",
"title": "This device doesnt support spam protection."
"title": "This device doesn't support spam protection."
},
"invalid_format": "Please enter a valid format",
"max_length": "Please enter no more than {max} characters",
"max_selections": "Please select no more than {max} options",
"max_value": "Please enter a value no greater than {max}",
"min_length": "Please enter at least {min} characters",
"min_selections": "Please select at least {min} options",
"min_value": "Please enter a value of at least {min}",
"please_book_an_appointment": "Please book an appointment",
"please_enter_a_valid_email_address": "Please enter a valid email address",
"please_enter_a_valid_phone_number": "Please enter a valid phone number",

View File

@@ -17,6 +17,7 @@ interface MultipleChoiceMultiElementProps {
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
errorMessage?: string; // Validation error from centralized validation
}
export function MultipleChoiceMultiElement({
@@ -28,10 +29,10 @@ export function MultipleChoiceMultiElement({
setTtc,
currentElementId,
dir = "auto",
errorMessage,
}: Readonly<MultipleChoiceMultiElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [otherValue, setOtherValue] = useState("");
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId;
const { t } = useTranslation();
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -173,22 +174,9 @@ export function MultipleChoiceMultiElement({
onChange({ [element.id]: nextValue });
};
const validateRequired = (): boolean => {
if (element.required && (!Array.isArray(value) || value.length === 0)) {
setErrorMessage(t("errors.please_select_an_option"));
return false;
}
if (element.required && isOtherSelected && !otherValue.trim()) {
setErrorMessage(t("errors.please_fill_out_this_field"));
return false;
}
return true;
};
const handleSubmit = (e: Event) => {
e.preventDefault();
setErrorMessage(undefined);
if (!validateRequired()) return;
// Update TTC when form is submitted (for TTC collection)
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
@@ -228,7 +216,6 @@ export function MultipleChoiceMultiElement({
// Handle selection changes - store labels directly instead of IDs
const handleMultiSelectChange = (selectedIds: string[]) => {
setErrorMessage(undefined);
const nextLabels: string[] = [];
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);

View File

@@ -1,7 +1,5 @@
import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { OpenText } from "@formbricks/survey-ui";
import { ZEmail, ZUrl } from "@formbricks/types/common";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
import { getLocalizedValue } from "@/lib/i18n";
@@ -18,6 +16,7 @@ interface OpenTextElementProps {
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
errorMessage?: string; // Validation error from centralized validation
}
export function OpenTextElement({
@@ -29,76 +28,19 @@ export function OpenTextElement({
setTtc,
currentElementId,
dir = "auto",
errorMessage,
}: Readonly<OpenTextElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation();
const handleChange = (inputValue: string) => {
// Clear error when user starts typing
setErrorMessage(undefined);
onChange({ [element.id]: inputValue });
};
const validateRequired = (): boolean => {
if (element.required && (!value || value.trim() === "")) {
setErrorMessage(t("errors.please_fill_out_this_field"));
return false;
}
return true;
};
const validateEmail = (): boolean => {
if (!ZEmail.safeParse(value).success) {
setErrorMessage(t("errors.please_enter_a_valid_email_address"));
return false;
}
return true;
};
const validateUrl = (): boolean => {
if (!ZUrl.safeParse(value).success) {
setErrorMessage(t("errors.please_enter_a_valid_url"));
return false;
}
return true;
};
const validatePhone = (): boolean => {
// Match the same pattern: must start with digit or +, end with digit
// Allows digits, +, -, and spaces in between
const phoneRegex = /^[0-9+][0-9+\- ]*[0-9]$/;
if (!phoneRegex.test(value)) {
setErrorMessage(t("errors.please_enter_a_valid_phone_number"));
return false;
}
return true;
};
const validateInput = (): boolean => {
if (!value || value.trim() === "") return true;
if (element.inputType === "email") {
return validateEmail();
}
if (element.inputType === "url") {
return validateUrl();
}
if (element.inputType === "phone") {
return validatePhone();
}
return true;
};
const handleOnSubmit = (e: Event) => {
e.preventDefault();
setErrorMessage(undefined);
if (!validateRequired()) return;
if (!validateInput()) return;
// Update TTC when form is submitted (for TTC collection)
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtc);
};

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";
@@ -9,12 +10,14 @@ import {
TSurveyMatrixElement,
TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ElementConditional } from "@/components/general/element-conditional";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { validateBlockResponses, getFirstErrorMessage } from "@/lib/validation";
interface BlockConditionalProps {
block: TSurveyBlock;
@@ -59,9 +62,14 @@ export function BlockConditional({
dir,
fullSizeCards,
}: BlockConditionalProps) {
const { t } = useTranslation();
// Track the current element being filled (for TTC tracking)
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
// State to store validation errors from centralized validation
const [elementErrors, setElementErrors] = useState<TValidationErrorMap>({});
// Refs to store form elements for each element so we can trigger their validation
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
@@ -74,6 +82,14 @@ export function BlockConditional({
if (elementId !== currentElementId) {
setCurrentElementId(elementId);
}
// Clear error for this element when user makes a change
if (elementErrors[elementId]) {
setElementErrors((prev) => {
const updated = { ...prev };
delete updated[elementId];
return updated;
});
}
// Merge with existing block data to preserve other element values
onChange({ ...value, ...responseData });
};
@@ -263,15 +279,34 @@ export function BlockConditional({
e.preventDefault();
}
// Validate all forms and check for custom validation rules
const firstInvalidForm = findFirstInvalidForm();
// Run centralized validation for elements that support it (OpenText, MultiSelect)
const errorMap = validateBlockResponses(block.elements, value, languageCode, t);
// If any form is invalid, scroll to it and stop
// Check if there are any validation errors from centralized validation
const hasValidationErrors = Object.keys(errorMap).length > 0;
if (hasValidationErrors) {
setElementErrors(errorMap);
// Find the first element with an error and scroll to it
const firstErrorElementId = Object.keys(errorMap)[0];
const form = elementFormRefs.current.get(firstErrorElementId);
if (form) {
form.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
// Also run legacy validation for elements not yet migrated to centralized validation
const firstInvalidForm = findFirstInvalidForm();
if (firstInvalidForm) {
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// Clear any previous errors
setElementErrors({});
// Collect TTC and responses, then submit
const blockTtc = collectTtcValues();
const blockResponses = collectBlockResponses();
@@ -310,6 +345,7 @@ export function BlockConditional({
}
}}
onTtcCollect={handleTtcCollect}
errorMessage={getFirstErrorMessage(elementErrors, element.id)}
/>
</div>
);

View File

@@ -39,6 +39,7 @@ interface ElementConditionalProps {
dir?: "ltr" | "rtl" | "auto";
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
errorMessage?: string; // Validation error message from centralized validation
}
export function ElementConditional({
@@ -56,6 +57,7 @@ export function ElementConditional({
dir,
formRef,
onTtcCollect,
errorMessage,
}: ElementConditionalProps) {
// Ref to the container div, used to find and expose the form element inside
const containerRef = useRef<HTMLDivElement>(null);
@@ -124,6 +126,7 @@ export function ElementConditional({
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
errorMessage={errorMessage}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
@@ -154,6 +157,7 @@ export function ElementConditional({
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
errorMessage={errorMessage}
/>
);
case TSurveyElementTypeEnum.NPS:

View File

@@ -0,0 +1,151 @@
import type { TFunction } from "i18next";
import type { TResponseData, TResponseDataValue } from "@formbricks/types/responses";
import type {
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyOpenTextElement,
} from "@formbricks/types/surveys/elements";
import type {
TValidationError,
TValidationErrorMap,
TValidationResult,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { validators } from "./validators";
/**
* Check if an element is an OpenText element with inputType
*/
const isOpenTextElement = (element: TSurveyElement): element is TSurveyOpenTextElement => {
return element.type === ("openText" as TSurveyElementTypeEnum);
};
/**
* Single entrypoint for validating an element's response value.
* Called by block-conditional.tsx during form submission.
*
* @param element - The survey element being validated
* @param value - The response value for this element
* @param languageCode - Current language code for error messages
* @param t - i18next translation function
* @returns Validation result with valid flag and array of errors
*/
export const validateElementResponse = (
element: TSurveyElement,
value: TResponseDataValue,
languageCode: string,
t: TFunction
): TValidationResult => {
const errors: TValidationError[] = [];
const rules: TValidationRule[] = [...(element.validationRules ?? [])];
// Handle legacy `required` field for backwards compatibility
// If element.required is true and no explicit "required" rule exists, add one
if (element.required && !rules.some((r) => r.params.type === "required")) {
const legacyRequiredRule: TValidationRule = {
id: "__legacy_required__",
params: { type: "required" },
enabled: true,
};
rules.unshift(legacyRequiredRule);
}
// Handle legacy `inputType` field for OpenText elements
// If inputType is email/url/phone and no explicit rule exists, add one
if (isOpenTextElement(element) && element.inputType) {
const inputTypeToRuleType: Record<string, TValidationRuleType> = {
email: "email",
url: "url",
phone: "phone",
};
const ruleType = inputTypeToRuleType[element.inputType];
if (ruleType && !rules.some((r) => r.params.type === ruleType)) {
const legacyInputTypeRule: TValidationRule = {
id: `__legacy_${element.inputType}__`,
params: { type: ruleType } as TValidationRule["params"],
enabled: true,
};
rules.push(legacyInputTypeRule);
}
}
for (const rule of rules) {
// Skip disabled rules
if (rule.enabled === false) {
continue;
}
const ruleType = rule.params.type as TValidationRuleType;
const validator = validators[ruleType];
if (!validator) {
console.warn(`Unknown validation rule type: ${ruleType}`);
continue;
}
const checkResult = validator.check(value, rule.params, element);
if (!checkResult.valid) {
// Use custom error message if provided, otherwise use default
const message =
rule.customErrorMessage?.[languageCode] ??
rule.customErrorMessage?.default ??
validator.getDefaultMessage(rule.params, t);
errors.push({
ruleId: rule.id,
ruleType,
message,
});
}
}
return { valid: errors.length === 0, errors };
};
/**
* Validate all elements in a block, returning an error map.
*
* @param elements - Array of elements to validate
* @param responses - Response data keyed by element ID
* @param languageCode - Current language code for error messages
* @param t - i18next translation function
* @returns Map of element IDs to their validation errors
*/
export const validateBlockResponses = (
elements: TSurveyElement[],
responses: TResponseData,
languageCode: string,
t: TFunction
): TValidationErrorMap => {
const errorMap: TValidationErrorMap = {};
for (const element of elements) {
const result = validateElementResponse(element, responses[element.id], languageCode, t);
if (!result.valid) {
errorMap[element.id] = result.errors;
}
}
return errorMap;
};
/**
* Get the first error message for an element from the error map.
* Useful for UI components that only display one error at a time.
*
* @param errorMap - The validation error map
* @param elementId - The element ID to get error for
* @returns The first error message or undefined
*/
export const getFirstErrorMessage = (
errorMap: TValidationErrorMap,
elementId: string
): string | undefined => {
const errors = errorMap[elementId];
return errors?.[0]?.message;
};

View File

@@ -0,0 +1,5 @@
export { validateElementResponse, validateBlockResponses, getFirstErrorMessage } from "./evaluator";
export { validators } from "./validators";
export type { TValidator, TValidatorCheckResult } from "./validators";

View File

@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import { ZEmail } from "@formbricks/types/common";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsEmail } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const emailValidator: TValidator<TValidationRuleParamsEmail> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsEmail, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: ZEmail.safeParse(value).success };
},
getDefaultMessage: (_params: TValidationRuleParamsEmail, t: TFunction): string => {
return t("errors.please_enter_a_valid_email_address");
},
};

View File

@@ -0,0 +1,34 @@
import type { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import type { TValidator } from "./types";
import { requiredValidator } from "./required";
import { minLengthValidator } from "./min-length";
import { maxLengthValidator } from "./max-length";
import { emailValidator } from "./email";
import { urlValidator } from "./url";
import { phoneValidator } from "./phone";
import { patternValidator } from "./pattern";
import { minValueValidator } from "./min-value";
import { maxValueValidator } from "./max-value";
import { minSelectionsValidator } from "./min-selections";
import { maxSelectionsValidator } from "./max-selections";
/**
* Registry of all validators, keyed by rule type.
* Each validator implements the TValidator interface.
* We use `as TValidator` to work around TypeScript's strict generics for the discriminated union.
*/
export const validators: Record<TValidationRuleType, TValidator> = {
required: requiredValidator as TValidator,
minLength: minLengthValidator as TValidator,
maxLength: maxLengthValidator as TValidator,
email: emailValidator as TValidator,
url: urlValidator as TValidator,
phone: phoneValidator as TValidator,
pattern: patternValidator as TValidator,
minValue: minValueValidator as TValidator,
maxValue: maxValueValidator as TValidator,
minSelections: minSelectionsValidator as TValidator,
maxSelections: maxSelectionsValidator as TValidator,
};
export type { TValidator, TValidatorCheckResult } from "./types";

View File

@@ -0,0 +1,21 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMaxLength } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const maxLengthValidator: TValidator<TValidationRuleParamsMaxLength> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxLength, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is not a string
if (typeof value !== "string") {
return { valid: true };
}
return { valid: value.length <= params.max };
},
getDefaultMessage: (params: TValidationRuleParamsMaxLength, t: TFunction): string => {
return t("errors.max_length", { max: params.max });
},
};

View File

@@ -0,0 +1,24 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMaxSelections } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
import { countSelections } from "./selection-utils";
export const maxSelectionsValidator: TValidator<TValidationRuleParamsMaxSelections> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxSelections, _element: TSurveyElement): TValidatorCheckResult => {
// If value is not an array, rule doesn't apply (graceful)
if (!Array.isArray(value)) {
return { valid: true };
}
const selectionCount = countSelections(value);
return { valid: selectionCount <= params.max };
},
getDefaultMessage: (params: TValidationRuleParamsMaxSelections, t: TFunction): string => {
return t("errors.max_selections", { max: params.max });
},
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMaxValue } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const maxValueValidator: TValidator<TValidationRuleParamsMaxValue> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxValue, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (value === undefined || value === null || value === "") {
return { valid: true };
}
// Handle string numbers (from OpenText with inputType=number)
const numValue = typeof value === "string" ? parseFloat(value) : typeof value === "number" ? value : NaN;
if (isNaN(numValue)) {
return { valid: true }; // Let pattern/type validation handle non-numeric
}
return { valid: numValue <= params.max };
},
getDefaultMessage: (params: TValidationRuleParamsMaxValue, t: TFunction): string => {
return t("errors.max_value", { max: params.max });
},
};

View File

@@ -0,0 +1,21 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMinLength } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const minLengthValidator: TValidator<TValidationRuleParamsMinLength> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMinLength, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is not a string or is empty (let required handle empty)
if (typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: value.length >= params.min };
},
getDefaultMessage: (params: TValidationRuleParamsMinLength, t: TFunction): string => {
return t("errors.min_length", { min: params.min });
},
};

View File

@@ -0,0 +1,24 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMinSelections } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
import { countSelections } from "./selection-utils";
export const minSelectionsValidator: TValidator<TValidationRuleParamsMinSelections> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMinSelections, _element: TSurveyElement): TValidatorCheckResult => {
// If value is not an array, check fails (need selections)
if (!Array.isArray(value)) {
return { valid: false };
}
const selectionCount = countSelections(value);
return { valid: selectionCount >= params.min };
},
getDefaultMessage: (params: TValidationRuleParamsMinSelections, t: TFunction): string => {
return t("errors.min_selections", { min: params.min });
},
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMinValue } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const minValueValidator: TValidator<TValidationRuleParamsMinValue> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMinValue, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (value === undefined || value === null || value === "") {
return { valid: true };
}
// Handle string numbers (from OpenText with inputType=number)
const numValue = typeof value === "string" ? parseFloat(value) : typeof value === "number" ? value : NaN;
if (isNaN(numValue)) {
return { valid: true }; // Let pattern/type validation handle non-numeric
}
return { valid: numValue >= params.min };
},
getDefaultMessage: (params: TValidationRuleParamsMinValue, t: TFunction): string => {
return t("errors.min_value", { min: params.min });
},
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsPattern } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const patternValidator: TValidator<TValidationRuleParamsPattern> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsPattern, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
try {
const regex = new RegExp(params.pattern, params.flags);
return { valid: regex.test(value) };
} catch {
// If regex is invalid, consider it valid (design-time should catch this)
console.warn(`Invalid regex pattern: ${params.pattern}`);
return { valid: true };
}
},
getDefaultMessage: (_params: TValidationRuleParamsPattern, t: TFunction): string => {
return t("errors.invalid_format");
},
};

View File

@@ -0,0 +1,25 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsPhone } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
// Phone regex: must start with digit or +, end with digit
// Allows digits, +, -, and spaces in between
const PHONE_REGEX = /^[0-9+][0-9+\- ]*[0-9]$/;
export const phoneValidator: TValidator<TValidationRuleParamsPhone> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsPhone, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: PHONE_REGEX.test(value) };
},
getDefaultMessage: (_params: TValidationRuleParamsPhone, t: TFunction): string => {
return t("errors.please_enter_a_valid_phone_number");
},
};

View File

@@ -0,0 +1,24 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsRequired } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const requiredValidator: TValidator<TValidationRuleParamsRequired> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsRequired, _element: TSurveyElement): TValidatorCheckResult => {
const isEmpty =
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0);
return { valid: !isEmpty };
},
getDefaultMessage: (_params: TValidationRuleParamsRequired, t: TFunction): string => {
return t("errors.please_fill_out_this_field");
},
};

View File

@@ -0,0 +1,44 @@
/**
* Count the number of actual selections in a multi-select value array.
*
* The value array format for MultiSelect:
* - Regular options: ["Label1", "Label2"]
* - With "other" option: ["Label1", "", "custom other text"]
* - The "" sentinel indicates "other" is selected
* - The text following it is the custom value
*
* This function counts logical selections, not array length.
*/
export const countSelections = (value: unknown[]): number => {
if (!Array.isArray(value) || value.length === 0) {
return 0;
}
const hasOtherSentinel = value.includes("");
let count = 0;
for (let i = 0; i < value.length; i++) {
const item = value[i];
// Skip empty sentinel
if (item === "") {
continue;
}
// Skip the value immediately after empty sentinel (it's the "other" custom text)
if (i > 0 && value[i - 1] === "") {
continue;
}
count++;
}
// Add 1 for "other" if it's selected (the sentinel + optional text count as 1 selection)
if (hasOtherSentinel) {
count++;
}
return count;
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParams } from "@formbricks/types/surveys/validation-rules";
/**
* Result of a validator check
*/
export interface TValidatorCheckResult {
valid: boolean;
}
/**
* Generic validator interface
* P = the specific params type for this validator
*/
export interface TValidator<P extends TValidationRuleParams = TValidationRuleParams> {
/**
* Check if the value passes validation
*/
check: (value: TResponseDataValue, params: P, element: TSurveyElement) => TValidatorCheckResult;
/**
* Get the default error message for this rule
*/
getDefaultMessage: (params: P, t: TFunction) => string;
}

View File

@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import { ZUrl } from "@formbricks/types/common";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsUrl } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const urlValidator: TValidator<TValidationRuleParamsUrl> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsUrl, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: ZUrl.safeParse(value).success };
},
getDefaultMessage: (_params: TValidationRuleParamsUrl, t: TFunction): string => {
return t("errors.please_enter_a_valid_url");
},
};

View File

@@ -3,6 +3,7 @@ import { ZUrl } from "../common";
import { ZI18nString } from "../i18n";
import { ZAllowedFileExtension } from "../storage";
import { FORBIDDEN_IDS } from "./validation";
import { ZValidationRules } from "./validation-rules";
// Element Type Enum (same as question types)
export enum TSurveyElementTypeEnum {
@@ -61,6 +62,7 @@ export const ZSurveyElementBase = z.object({
scale: z.enum(["number", "smiley", "star"]).optional(),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
isDraft: z.boolean().optional(),
validationRules: ZValidationRules.optional(),
});
// OpenText Element
@@ -162,7 +164,7 @@ export const ZSurveyCTAElement = ZSurveyElementBase.extend({
buttonUrl: z.string().optional(),
ctaButtonLabel: ZI18nString.optional(),
}).superRefine((data, ctx) => {
// When buttonExternal is true, buttonUrl is required and must be valid
// When buttonExternal is true, buttonUrl and ctaButtonLabel are required
if (data.buttonExternal) {
if (!data.buttonUrl || data.buttonUrl.trim() === "") {
ctx.addIssue({
@@ -181,6 +183,14 @@ export const ZSurveyCTAElement = ZSurveyElementBase.extend({
});
}
}
if (!data.ctaButtonLabel?.default || data.ctaButtonLabel.default.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Button label is required when external button is enabled",
path: ["ctaButtonLabel"],
});
}
}
});

View File

@@ -0,0 +1,159 @@
import { z } from "zod";
import { ZI18nString } from "../i18n";
// Validation rule type enum - extensible for future rule types
export const ZValidationRuleType = z.enum([
// Universal rules
"required",
// Text/OpenText rules
"minLength",
"maxLength",
"pattern",
"email",
"url",
"phone",
// Numeric rules (for OpenText inputType=number)
"minValue",
"maxValue",
// Selection rules (MultiSelect, PictureSelection)
"minSelections",
"maxSelections",
]);
export type TValidationRuleType = z.infer<typeof ZValidationRuleType>;
// Rule params - discriminated union for type-safe params per rule type
export const ZValidationRuleParamsRequired = z.object({
type: z.literal("required"),
});
export const ZValidationRuleParamsMinLength = z.object({
type: z.literal("minLength"),
min: z.number().min(0),
});
export const ZValidationRuleParamsMaxLength = z.object({
type: z.literal("maxLength"),
max: z.number().min(1),
});
export const ZValidationRuleParamsPattern = z.object({
type: z.literal("pattern"),
pattern: z.string().min(1),
flags: z.string().optional(),
});
export const ZValidationRuleParamsEmail = z.object({
type: z.literal("email"),
});
export const ZValidationRuleParamsUrl = z.object({
type: z.literal("url"),
});
export const ZValidationRuleParamsPhone = z.object({
type: z.literal("phone"),
});
export const ZValidationRuleParamsMinValue = z.object({
type: z.literal("minValue"),
min: z.number(),
});
export const ZValidationRuleParamsMaxValue = z.object({
type: z.literal("maxValue"),
max: z.number(),
});
export const ZValidationRuleParamsMinSelections = z.object({
type: z.literal("minSelections"),
min: z.number().min(1),
});
export const ZValidationRuleParamsMaxSelections = z.object({
type: z.literal("maxSelections"),
max: z.number().min(1),
});
// Union of all params types
export const ZValidationRuleParams = z.discriminatedUnion("type", [
ZValidationRuleParamsRequired,
ZValidationRuleParamsMinLength,
ZValidationRuleParamsMaxLength,
ZValidationRuleParamsPattern,
ZValidationRuleParamsEmail,
ZValidationRuleParamsUrl,
ZValidationRuleParamsPhone,
ZValidationRuleParamsMinValue,
ZValidationRuleParamsMaxValue,
ZValidationRuleParamsMinSelections,
ZValidationRuleParamsMaxSelections,
]);
export type TValidationRuleParams = z.infer<typeof ZValidationRuleParams>;
// Extract specific param types for validators
export type TValidationRuleParamsRequired = z.infer<typeof ZValidationRuleParamsRequired>;
export type TValidationRuleParamsMinLength = z.infer<typeof ZValidationRuleParamsMinLength>;
export type TValidationRuleParamsMaxLength = z.infer<typeof ZValidationRuleParamsMaxLength>;
export type TValidationRuleParamsPattern = z.infer<typeof ZValidationRuleParamsPattern>;
export type TValidationRuleParamsEmail = z.infer<typeof ZValidationRuleParamsEmail>;
export type TValidationRuleParamsUrl = z.infer<typeof ZValidationRuleParamsUrl>;
export type TValidationRuleParamsPhone = z.infer<typeof ZValidationRuleParamsPhone>;
export type TValidationRuleParamsMinValue = z.infer<typeof ZValidationRuleParamsMinValue>;
export type TValidationRuleParamsMaxValue = z.infer<typeof ZValidationRuleParamsMaxValue>;
export type TValidationRuleParamsMinSelections = z.infer<typeof ZValidationRuleParamsMinSelections>;
export type TValidationRuleParamsMaxSelections = z.infer<typeof ZValidationRuleParamsMaxSelections>;
// Validation rule stored on element
export const ZValidationRule = z.object({
id: z.string(),
params: ZValidationRuleParams,
customErrorMessage: ZI18nString.optional(),
enabled: z.boolean().default(true),
});
export type TValidationRule = z.infer<typeof ZValidationRule>;
// Array of validation rules
export const ZValidationRules = z.array(ZValidationRule);
export type TValidationRules = z.infer<typeof ZValidationRules>;
// Validation error returned by evaluator
export interface TValidationError {
ruleId: string;
ruleType: TValidationRuleType;
message: string;
}
// Validation result for a single element
export interface TValidationResult {
valid: boolean;
errors: TValidationError[];
}
// Error map for block-level validation (keyed by elementId)
export type TValidationErrorMap = Record<string, TValidationError[]>;
// Applicable rules per element type
export const APPLICABLE_RULES: Record<string, TValidationRuleType[]> = {
openText: ["required", "minLength", "maxLength", "pattern", "email", "url", "phone", "minValue", "maxValue"],
multipleChoiceSingle: ["required"],
multipleChoiceMulti: ["required", "minSelections", "maxSelections"],
rating: ["required"],
nps: ["required"],
date: ["required"],
consent: ["required"],
matrix: ["required"],
ranking: ["required"],
fileUpload: ["required"],
pictureSelection: ["required", "minSelections", "maxSelections"],
address: ["required"],
contactInfo: ["required"],
cal: ["required"],
cta: [], // CTA never validates
};

1032
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -274,6 +274,7 @@
"outputs": []
},
"db:seed": {
"env": ["ALLOW_SEED"],
"outputs": []
},
"db:setup": {