Compare commits

...

12 Commits

Author SHA1 Message Date
pandeymangg
b23a836ab6 adds test 2026-01-06 14:29:42 +05:30
pandeymangg
2785ff3fb3 adds logs 2026-01-06 14:08:33 +05:30
pandeymangg
248edd80cb Merge remote-tracking branch 'origin/main' into fix/api-wrapper-malformed-response 2026-01-06 13:59:41 +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
Jagadish Madavalkar
b2506ffde1 fixes api-wrapper for valid malformed response 2026-01-01 14:17:32 +00:00
25 changed files with 1905 additions and 458 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

@@ -1935,7 +1935,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

@@ -1,4 +1,5 @@
import { ZodRawShape, z } from "zod";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
@@ -67,7 +68,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
const bodyData = await request.json();
let bodyData;
try {
bodyData = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return handleApiError(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {

View File

@@ -132,6 +132,71 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("should handle malformed JSON input in request body", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: "{ invalid json }",
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
});
test("should handle empty body when body schema is provided", async () => {
const request = new Request("http://localhost", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
});
test("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");

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

@@ -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

@@ -162,7 +162,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 +181,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"],
});
}
}
});

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": {