From 36535e1e508ebb75a28d66af2c4ff57d0814c6f8 Mon Sep 17 00:00:00 2001 From: Johannes Date: Wed, 15 Oct 2025 18:07:04 +0200 Subject: [PATCH] feat: Add language as default contact attribute for case-insensitive CSV matching - Add language as a default attribute key in environment creation - Create data migration to add language attribute key to existing environments - Update tests to verify language is treated like other default attributes - Fixes issue where CSV columns with 'Language' (capital L) would create duplicate custom attributes The existing isStringMatch() function already handles case-insensitive matching, so this change ensures language is properly matched alongside userId, email, firstName, and lastName without any hardcoding in the UI layer. --- apps/web/lib/environment/service.ts | 6 ++ .../components/upload-contacts-button.tsx | 12 ++-- .../modules/ee/contacts/lib/contacts.test.ts | 45 ++++++++++++++ .../migration.ts | 60 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 packages/database/migration/20251016000000_add_language_attribute_key/migration.ts diff --git a/apps/web/lib/environment/service.ts b/apps/web/lib/environment/service.ts index 044d9e10a5..840726442a 100644 --- a/apps/web/lib/environment/service.ts +++ b/apps/web/lib/environment/service.ts @@ -168,6 +168,12 @@ export const createEnvironment = async ( description: "Your contact's last name", type: "default", }, + { + key: "language", + name: "Language", + description: "The language preference of a contact", + type: "default", + }, ], }, }, diff --git a/apps/web/modules/ee/contacts/components/upload-contacts-button.tsx b/apps/web/modules/ee/contacts/components/upload-contacts-button.tsx index 57367ee37b..aa281e8c54 100644 --- a/apps/web/modules/ee/contacts/components/upload-contacts-button.tsx +++ b/apps/web/modules/ee/contacts/components/upload-contacts-button.tsx @@ -1,5 +1,11 @@ "use client"; +import { useTranslate } from "@tolgee/react"; +import { parse } from "csv-parse/sync"; +import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { cn } from "@/lib/cn"; import { isStringMatch } from "@/lib/utils/helper"; import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions"; @@ -18,12 +24,6 @@ import { DialogTitle, } from "@/modules/ui/components/dialog"; import { StylingTabs } from "@/modules/ui/components/styling-tabs"; -import { useTranslate } from "@tolgee/react"; -import { parse } from "csv-parse/sync"; -import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; interface UploadContactsCSVButtonProps { environmentId: string; diff --git a/apps/web/modules/ee/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/lib/contacts.test.ts index b32374a844..8fa8d0e8de 100644 --- a/apps/web/modules/ee/contacts/lib/contacts.test.ts +++ b/apps/web/modules/ee/contacts/lib/contacts.test.ts @@ -319,6 +319,51 @@ describe("createContactsFromCSV", () => { createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" }) ).rejects.toThrow(genericError); }); + + test("handles language attribute key like other default attributes", async () => { + vi.mocked(prisma.contact.findMany).mockResolvedValue([]); + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); + vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([ + { key: "email", id: "id-email" }, + { key: "userId", id: "id-userId" }, + { key: "firstName", id: "id-firstName" }, + { key: "lastName", id: "id-lastName" }, + { key: "language", id: "id-language" }, + ] as any); + vi.mocked(prisma.contact.create).mockResolvedValue({ + id: "c1", + environmentId, + createdAt: new Date(), + updatedAt: new Date(), + attributes: [ + { attributeKey: { key: "email" }, value: "john@example.com" }, + { attributeKey: { key: "userId" }, value: "user123" }, + { attributeKey: { key: "firstName" }, value: "John" }, + { attributeKey: { key: "lastName" }, value: "Doe" }, + { attributeKey: { key: "language" }, value: "en" }, + ], + } as any); + const csvData = [ + { + email: "john@example.com", + userId: "user123", + firstName: "John", + lastName: "Doe", + language: "en", + }, + ]; + const result = await createContactsFromCSV(csvData, environmentId, "skip", { + email: "email", + userId: "userId", + firstName: "firstName", + lastName: "lastName", + language: "language", + }); + expect(Array.isArray(result)).toBe(true); + expect(result[0].id).toBe("c1"); + // language attribute key should already exist, no need to create it + expect(prisma.contactAttributeKey.createMany).not.toHaveBeenCalled(); + }); }); describe("buildContactWhereClause", () => { diff --git a/packages/database/migration/20251016000000_add_language_attribute_key/migration.ts b/packages/database/migration/20251016000000_add_language_attribute_key/migration.ts new file mode 100644 index 0000000000..9f49106cd4 --- /dev/null +++ b/packages/database/migration/20251016000000_add_language_attribute_key/migration.ts @@ -0,0 +1,60 @@ +/* eslint-disable no-constant-condition -- Required for the while loop */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */ +import { createId } from "@paralleldrive/cuid2"; +import { logger } from "@formbricks/logger"; +import type { MigrationScript } from "../../src/scripts/migration-runner"; + +export const addLanguageAttributeKey: MigrationScript = { + type: "data", + id: "add_language_attribute_key_v1", + name: "20251016000000_add_language_attribute_key", + run: async ({ tx }) => { + const BATCH_SIZE = 1000; + let skip = 0; + let totalProcessed = 0; + + logger.info("Starting migration to add language attribute key to environments"); + + while (true) { + // Fetch environments in batches + const environments = await tx.$queryRaw<{ id: string }[]>` + SELECT id FROM "Environment" + LIMIT ${BATCH_SIZE} OFFSET ${skip} + `; + + if (environments.length === 0) { + break; + } + + logger.info(`Processing ${environments.length.toString()} environments`); + + // Process each environment + for (const env of environments) { + // Insert language attribute key if it doesn't exist + await tx.$executeRaw` + INSERT INTO "ContactAttributeKey" ( + "id", "created_at", "updated_at", "key", "name", "description", "type", "isUnique", "environmentId" + ) VALUES ( + ${createId()}, + NOW(), + NOW(), + 'language', + 'Language', + 'The language preference of a contact', + 'default', + false, + ${env.id} + ) + ON CONFLICT ("key", "environmentId") DO NOTHING + `; + } + + totalProcessed += environments.length; + skip += BATCH_SIZE; + + logger.info(`Processed ${totalProcessed.toString()} environments so far`); + } + + logger.info(`Migration completed. Total environments processed: ${totalProcessed.toString()}`); + }, +};