diff --git a/apps/web/modules/ee/contacts/lib/attribute-storage.ts b/apps/web/modules/ee/contacts/lib/attribute-storage.ts index 7c9716ab5bb0..81671b11716f 100644 --- a/apps/web/modules/ee/contacts/lib/attribute-storage.ts +++ b/apps/web/modules/ee/contacts/lib/attribute-storage.ts @@ -54,7 +54,6 @@ export const prepareNewSDKAttributeForStorage = ( }; const handleStringType = (value: TRawValue): TAttributeStorageColumns => { - // String type - only use value column let stringValue: string; if (value instanceof Date) { diff --git a/apps/web/modules/ee/contacts/lib/attributes.test.ts b/apps/web/modules/ee/contacts/lib/attributes.test.ts index 1e57bd488107..1bd612876cd3 100644 --- a/apps/web/modules/ee/contacts/lib/attributes.test.ts +++ b/apps/web/modules/ee/contacts/lib/attributes.test.ts @@ -437,4 +437,22 @@ describe("updateAttributes", () => { expect(result.success).toBe(true); expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} }); }); + + test("coerces boolean attribute values to strings", 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: true, email: "john@example.com" }; + const result = await updateAttributes(contactId, userId, environmentId, attributes); + + expect(result.success).toBe(true); + expect(prisma.$transaction).toHaveBeenCalled(); + const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0]; + // Both name (coerced from boolean) and email should be upserted + expect(transactionCall).toHaveLength(2); + }); }); diff --git a/apps/web/modules/ee/contacts/lib/attributes.ts b/apps/web/modules/ee/contacts/lib/attributes.ts index ddc66a88477b..61f4c13d80c1 100644 --- a/apps/web/modules/ee/contacts/lib/attributes.ts +++ b/apps/web/modules/ee/contacts/lib/attributes.ts @@ -130,7 +130,12 @@ export const updateAttributes = async ( const messages: TAttributeUpdateMessage[] = []; const errors: TAttributeUpdateMessage[] = []; - // Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully) + // Coerce boolean values to strings (SDK may send booleans for string attributes) + const coercedAttributes: Record = {}; + for (const [key, value] of Object.entries(contactAttributesParam)) { + coercedAttributes[key] = typeof value === "boolean" ? String(value) : value; + } + const emailValue = contactAttributesParam.email === null || contactAttributesParam.email === undefined ? null @@ -154,7 +159,7 @@ export const updateAttributes = async ( const userIdExists = !!existingUserIdAttribute; // Remove email and/or userId from attributes if they already exist on another contact - let contactAttributes = { ...contactAttributesParam }; + let contactAttributes = { ...coercedAttributes }; // 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 diff --git a/packages/types/contact-attribute.ts b/packages/types/contact-attribute.ts index 777b5ededfaf..238defb2afa6 100644 --- a/packages/types/contact-attribute.ts +++ b/packages/types/contact-attribute.ts @@ -16,5 +16,5 @@ export type TContactAttribute = z.infer; export const ZContactAttributes = z.record(z.string()); export type TContactAttributes = z.infer; -export const ZContactAttributesInput = z.record(z.union([z.string(), z.number()])); +export const ZContactAttributesInput = z.record(z.union([z.string(), z.number(), z.boolean()])); export type TContactAttributesInput = z.infer;