diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts index de1b9d91ad..b6002cd8d0 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors"; +import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; import { capturePostHogEvent } from "@/lib/posthog"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; @@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient ); if (!contactsResult || contactsResult.length === 0) { - throw new UnknownError("No contacts found for the selected segment"); + throw new InvalidInputError("No contacts found for the selected segment"); } capturePostHogEvent( diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 623f510868..f7cd9154db 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1859,6 +1859,7 @@ checksums: workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8 workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75 + workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93 @@ -1893,6 +1894,7 @@ checksums: workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8 workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6 workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328 + workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474 workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4 workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64 workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 7422e97042..e05268f087 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.", "attribute_key_placeholder": "z. B. geburtsdatum", "attribute_key_required": "Schlüssel ist erforderlich", + "attribute_key_reserved_future_default": "Der Schlüssel ist für zukünftige Standardattribute reserviert ({reservedKeys}). Bitte wähle einen anderen Schlüssel.", "attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen", "attribute_label": "Bezeichnung", "attribute_label_placeholder": "z. B. Geburtsdatum", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Persönlichen Link erstellen", "generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu erstellen.", "invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.", + "invalid_csv_reserved_column_names": "Reservierte CSV-Spaltennamen: {columns}. Diese Namen sind für zukünftige Standardattribute ({reservedKeys}) reserviert und können nicht als neue Attribute erstellt werden.", "invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.", "invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.", "no_activity_yet": "Noch keine Aktivität", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 7d444dc3dd..3e8e548dd6 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.", "attribute_key_placeholder": "e.g. date_of_birth", "attribute_key_required": "Key is required", + "attribute_key_reserved_future_default": "Key is reserved for future default attributes ({reservedKeys}). Please choose a different key.", "attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter", "attribute_label": "Label", "attribute_label_placeholder": "e.g. Date of Birth", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Generate Personal Link", "generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.", "invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.", + "invalid_csv_reserved_column_names": "Reserved CSV column name(s): {columns}. These names are reserved for future default attributes ({reservedKeys}) and cannot be created as new attributes.", "invalid_date_format": "Invalid date format. Please use a valid date.", "invalid_number_format": "Invalid number format. Please enter a valid number.", "no_activity_yet": "No activity yet", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 9533eb8984..bb3edbef43 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.", "attribute_key_placeholder": "p. ej. fecha_de_nacimiento", "attribute_key_required": "La clave es obligatoria", + "attribute_key_reserved_future_default": "La clave está reservada para atributos predeterminados futuros ({reservedKeys}). Por favor, elige una clave diferente.", "attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra", "attribute_label": "Etiqueta", "attribute_label_placeholder": "p. ej. fecha de nacimiento", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Generar enlace personal", "generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.", "invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.", + "invalid_csv_reserved_column_names": "Nombre(s) de columna CSV reservado(s): {columns}. Estos nombres están reservados para atributos predeterminados futuros ({reservedKeys}) y no se pueden crear como nuevos atributos.", "invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.", "invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.", "no_activity_yet": "Aún no hay actividad", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 5d5c19aba0..c3afb910fc 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.", "attribute_key_placeholder": "ex. date_de_naissance", "attribute_key_required": "La clé est requise", + "attribute_key_reserved_future_default": "La clé est réservée pour les attributs par défaut futurs ({reservedKeys}). Veuillez choisir une clé différente.", "attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr : uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre", "attribute_label": "Étiquette", "attribute_label_placeholder": "ex. Date de naissance", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Générer un lien personnel", "generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.", "invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s) : {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.", + "invalid_csv_reserved_column_names": "Nom(s) de colonne CSV réservé(s) : {columns}. Ces noms sont réservés pour les attributs par défaut futurs ({reservedKeys}) et ne peuvent pas être créés en tant que nouveaux attributs.", "invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.", "invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.", "no_activity_yet": "Aucune activité pour le moment", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index f632e6b181..c8da598fa1 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók. Betűvel kell kezdődnie.", "attribute_key_placeholder": "például: szuletesi_ido", "attribute_key_required": "A kulcs kötelező", + "attribute_key_reserved_future_default": "A kulcs le van foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}). Kérem, válasszon egy másik kulcsot.", "attribute_key_safe_identifier_required": "A kulcs csak biztonságos azonosító lehet: csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók, és betűvel kell kezdődnie", "attribute_label": "Címke", "attribute_label_placeholder": "például: Születési idő", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Személyes hivatkozás előállítása", "generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.", "invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.", + "invalid_csv_reserved_column_names": "Fenntartott CSV oszlopnév/nevek: {columns}. Ezek a nevek le vannak foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}), és nem hozhatók létre új attribútumokként.", "invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.", "invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.", "no_activity_yet": "Még nincs tevékenység", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index f585407b7e..001f9a288c 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。", "attribute_key_placeholder": "例: date_of_birth", "attribute_key_required": "キーは必須です", + "attribute_key_reserved_future_default": "このキーは将来のデフォルト属性用に予約されています({reservedKeys})。別のキーを選択してください。", "attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります", "attribute_label": "ラベル", "attribute_label_placeholder": "例: 生年月日", @@ -1969,6 +1970,7 @@ "generate_personal_link": "個人リンクを生成", "generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。", "invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。", + "invalid_csv_reserved_column_names": "予約されたCSV列名: {columns}。これらの名前は将来のデフォルト属性({reservedKeys})用に予約されており、新しい属性として作成できません。", "invalid_date_format": "無効な日付形式です。有効な日付を使用してください。", "invalid_number_format": "無効な数値形式です。有効な数値を入力してください。", "no_activity_yet": "まだアクティビティがありません", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index ebddca4dcf..fa7e5308c3 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.", "attribute_key_placeholder": "bijv. geboortedatum", "attribute_key_required": "Sleutel is verplicht", + "attribute_key_reserved_future_default": "Sleutel is gereserveerd voor toekomstige standaardattributen ({reservedKeys}). Kies een andere sleutel.", "attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter", "attribute_label": "Label", "attribute_label_placeholder": "bijv. Geboortedatum", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Persoonlijke link genereren", "generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.", "invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.", + "invalid_csv_reserved_column_names": "Gereserveerde CSV-kolomnaam/namen: {columns}. Deze namen zijn gereserveerd voor toekomstige standaardattributen ({reservedKeys}) en kunnen niet als nieuwe attributen worden aangemaakt.", "invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.", "invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.", "no_activity_yet": "Nog geen activiteit", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 5fd8524267..8b263048d3 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.", "attribute_key_placeholder": "ex: data_de_nascimento", "attribute_key_required": "A chave é obrigatória", + "attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolha uma chave diferente.", "attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra", "attribute_label": "Etiqueta", "attribute_label_placeholder": "ex: Data de nascimento", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Gerar link pessoal", "generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.", "invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.", + "invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Esses nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.", "invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.", "invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.", "no_activity_yet": "Nenhuma atividade ainda", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 8ba8ee40bd..fe0b4bd31b 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.", "attribute_key_placeholder": "ex. data_de_nascimento", "attribute_key_required": "A chave é obrigatória", + "attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolhe uma chave diferente.", "attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra", "attribute_label": "Etiqueta", "attribute_label_placeholder": "ex. Data de nascimento", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Gerar Link Pessoal", "generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.", "invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.", + "invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Estes nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.", "invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.", "invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.", "no_activity_yet": "Ainda sem atividade", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 740cf219a0..366fd4a365 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.", "attribute_key_placeholder": "ex: date_of_birth", "attribute_key_required": "Cheia este obligatorie", + "attribute_key_reserved_future_default": "Cheia este rezervată pentru atribute implicite viitoare ({reservedKeys}). Te rugăm să alegi o cheie diferită.", "attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă", "attribute_label": "Etichetă", "attribute_label_placeholder": "ex: Data nașterii", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Generează link personal", "generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.", "invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.", + "invalid_csv_reserved_column_names": "Nume de coloană CSV rezervate: {columns}. Aceste nume sunt rezervate pentru atribute implicite viitoare ({reservedKeys}) și nu pot fi create ca atribute noi.", "invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.", "invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.", "no_activity_yet": "Nicio activitate încă", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 037edbd56d..1021c92c2b 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.", "attribute_key_placeholder": "например, date_of_birth", "attribute_key_required": "Ключ обязателен", + "attribute_key_reserved_future_default": "Ключ зарезервирован для будущих атрибутов по умолчанию ({reservedKeys}). Пожалуйста, выбери другой ключ.", "attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы", "attribute_label": "Метка", "attribute_label_placeholder": "например, дата рождения", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Сгенерировать персональную ссылку", "generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.", "invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.", + "invalid_csv_reserved_column_names": "Зарезервированные названия столбцов CSV: {columns}. Эти названия зарезервированы для будущих атрибутов по умолчанию ({reservedKeys}) и не могут быть созданы как новые атрибуты.", "invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.", "invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.", "no_activity_yet": "Пока нет активности", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index ef65468bc9..1b1abb2cc1 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.", "attribute_key_placeholder": "t.ex. date_of_birth", "attribute_key_required": "Nyckel krävs", + "attribute_key_reserved_future_default": "Nyckeln är reserverad för framtida standardattribut ({reservedKeys}). Välj en annan nyckel.", "attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav", "attribute_label": "Etikett", "attribute_label_placeholder": "t.ex. Födelsedatum", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Generera personlig länk", "generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.", "invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.", + "invalid_csv_reserved_column_names": "Reserverade CSV-kolumnnamn: {columns}. Dessa namn är reserverade för framtida standardattribut ({reservedKeys}) och kan inte skapas som nya attribut.", "invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.", "invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.", "no_activity_yet": "Ingen aktivitet än", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index e5b37a7a76..67775528a9 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "Yalnızca küçük harfler, rakamlar ve alt çizgiler. Bir harfle başlamalıdır.", "attribute_key_placeholder": "örn. dogum_tarihi", "attribute_key_required": "Anahtar gereklidir", + "attribute_key_reserved_future_default": "Anahtar, gelecekteki varsayılan özellikler için ayrılmıştır ({reservedKeys}). Lütfen farklı bir anahtar seçin.", "attribute_key_safe_identifier_required": "Anahtar güvenli bir tanımlayıcı olmalıdır: yalnızca küçük harfler, rakamlar ve alt çizgiler içermeli ve bir harfle başlamalıdır", "attribute_label": "Etiket", "attribute_label_placeholder": "örn. Doğum Tarihi", @@ -1969,6 +1970,7 @@ "generate_personal_link": "Kişisel Bağlantı Oluştur", "generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir anket seç.", "invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harf, rakam ve alt çizgi içerebilir ve bir harfle başlamalıdır.", + "invalid_csv_reserved_column_names": "Ayrılmış CSV sütun adı/adları: {columns}. Bu adlar gelecekteki varsayılan özellikler ({reservedKeys}) için ayrılmıştır ve yeni özellik olarak oluşturulamaz.", "invalid_date_format": "Geçersiz tarih formatı. Lütfen geçerli bir tarih kullanın.", "invalid_number_format": "Geçersiz sayı formatı. Lütfen geçerli bir sayı girin.", "no_activity_yet": "Henüz aktivite yok", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index ca4fd3496c..e7e590069d 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。", "attribute_key_placeholder": "例如:date_of_birth", "attribute_key_required": "键为必填项", + "attribute_key_reserved_future_default": "该键已保留用于未来的默认属性({reservedKeys})。请选择其他键。", "attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头", "attribute_label": "标签", "attribute_label_placeholder": "例如:出生日期", @@ -1969,6 +1970,7 @@ "generate_personal_link": "生成个人链接", "generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。", "invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。", + "invalid_csv_reserved_column_names": "CSV 列名已被保留:{columns}。这些名称已保留用于未来的默认属性({reservedKeys}),无法创建为新属性。", "invalid_date_format": "日期格式无效。请使用有效日期。", "invalid_number_format": "数字格式无效。请输入有效的数字。", "no_activity_yet": "暂无活动", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index bbe53ff8f1..18c780597f 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1935,6 +1935,7 @@ "attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。", "attribute_key_placeholder": "例如:date_of_birth", "attribute_key_required": "金鑰為必填項目", + "attribute_key_reserved_future_default": "此鍵已保留供未來預設屬性使用({reservedKeys})。請選擇其他鍵。", "attribute_key_safe_identifier_required": "金鑰必須為安全識別字:僅限小寫字母、數字和底線,且必須以字母開頭", "attribute_label": "標籤", "attribute_label_placeholder": "例如:出生日期", @@ -1969,6 +1970,7 @@ "generate_personal_link": "產生個人連結", "generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。", "invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。", + "invalid_csv_reserved_column_names": "保留的 CSV 欄位名稱:{columns}。這些名稱已保留供未來預設屬性使用({reservedKeys}),無法建立為新屬性。", "invalid_date_format": "日期格式無效。請使用有效的日期。", "invalid_number_format": "數字格式無效。請輸入有效的數字。", "no_activity_yet": "尚無活動", diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts index 3dccbaeeda..5eddb03ffe 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts @@ -10,6 +10,10 @@ import { TGetContactAttributeKeysFilter, } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { + getReservedFutureDefaultAttributeKeyIssue, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; export const getContactAttributeKeys = reactCache( async (workspaceIds: string[], params: TGetContactAttributeKeysFilter) => { @@ -45,6 +49,13 @@ export const createContactAttributeKey = async ( ): Promise> => { const { workspaceId, name, description, key, dataType } = contactAttributeKey; + if (isReservedFutureDefaultAttributeKey(key)) { + return err({ + type: "bad_request", + details: [{ field: "key", issue: getReservedFutureDefaultAttributeKeyIssue([key]) }], + }); + } + try { const prismaData: Prisma.ContactAttributeKeyCreateInput = { workspace: { diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts index 7f2cc84b81..a86ee2190e 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts @@ -105,6 +105,28 @@ describe("createContactAttributeKey", () => { } }); + test("returns bad request when key is reserved for future defaults", async () => { + const result = await createContactAttributeKey({ + ...inputContactAttributeKey, + key: "user_id", + }); + expect(result.ok).toBe(false); + expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled(); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "bad_request", + details: [ + { + field: "key", + issue: + "Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.", + }, + ], + }); + } + }); + test("returns conflict error when key already exists", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { code: PrismaErrorType.UniqueConstraintViolation, diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts index 6e812c74fa..36771a9ed3 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; import { ZGetFilter } from "@/modules/api/v2/types/api-filter"; +import { + getReservedFutureDefaultAttributeKeyIssue, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({}) .refine( @@ -38,6 +42,14 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({ path: ["key"], }); } + + if (isReservedFutureDefaultAttributeKey(data.key)) { + ctx.addIssue({ + code: "custom", + message: getReservedFutureDefaultAttributeKeyIssue([data.key]), + path: ["key"], + }); + } }) .meta({ id: "contactAttributeKeyInput", @@ -65,6 +77,14 @@ export const ZContactAttributeKeyCreateInput = ZContactAttributeKey.pick({ path: ["key"], }); } + + if (isReservedFutureDefaultAttributeKey(data.key)) { + ctx.addIssue({ + code: "custom", + message: getReservedFutureDefaultAttributeKeyIssue([data.key]), + path: ["key"], + }); + } }) .meta({ id: "contactAttributeKeyCreateInput", diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts index 494e7181f6..41b4c06027 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts @@ -3,8 +3,12 @@ import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { validateInputs } from "@/lib/utils/validate"; +import { + getReservedFutureDefaultAttributeKeyIssue, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { TContactAttributeKeyUpdateInput, ZContactAttributeKeyUpdateInput, @@ -56,6 +60,10 @@ export const updateContactAttributeKey = async ( ): Promise => { validateInputs([contactAttributeKeyId, ZId], [data, ZContactAttributeKeyUpdateInput]); + if (data.key && isReservedFutureDefaultAttributeKey(data.key)) { + throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key])); + } + try { const contactAttributeKey = await prisma.contactAttributeKey.update({ where: { diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts index 1d55a8ec25..35872932d4 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts @@ -1,12 +1,21 @@ import { z } from "zod"; import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key"; import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; +import { + RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; export const ZContactAttributeKeyCreateInput = z.object({ - key: z.string().refine((val) => isSafeIdentifier(val), { - error: - "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter", - }), + key: z + .string() + .refine((val) => isSafeIdentifier(val), { + error: + "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter", + }) + .refine((val) => !isReservedFutureDefaultAttributeKey(val), { + error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE, + }), description: z.string().optional(), type: z.enum(["custom"]), dataType: ZContactAttributeDataType.optional(), @@ -24,6 +33,9 @@ export const ZContactAttributeKeyUpdateInput = z.object({ error: "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter", }) + .refine((val) => !isReservedFutureDefaultAttributeKey(val), { + error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE, + }) .optional(), dataType: ZContactAttributeDataType.optional(), }); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts index dfea7c9201..1509e53233 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key"; -import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors"; import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants"; import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; import { createContactAttributeKey, getContactAttributeKeys } from "./contact-attribute-keys"; @@ -144,6 +144,17 @@ describe("createContactAttributeKey", () => { expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled(); }); + test("should throw InvalidInputError when key is reserved for future defaults", async () => { + await expect( + createContactAttributeKey(workspaceId, { + ...createInput, + key: "user_id", + }) + ).rejects.toThrow(InvalidInputError); + expect(prisma.contactAttributeKey.count).not.toHaveBeenCalled(); + expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled(); + }); + test("should throw DatabaseError if Prisma create fails", async () => { vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0); const errorMessage = "Prisma create error"; diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts index 2f77a85eac..e4e637ef0f 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts @@ -3,10 +3,14 @@ import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; -import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors"; import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants"; import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier"; import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { + getReservedFutureDefaultAttributeKeyIssue, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; export const getContactAttributeKeys = reactCache( async (workspaceIds: string[]): Promise => { @@ -29,6 +33,10 @@ export const createContactAttributeKey = async ( workspaceId: string, data: TContactAttributeKeyCreateInput ): Promise => { + if (isReservedFutureDefaultAttributeKey(data.key)) { + throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key])); + } + const contactAttributeKeysCount = await prisma.contactAttributeKey.count({ where: { workspaceId, diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts index 4809687313..60b8d431b4 100644 --- a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts @@ -6,6 +6,10 @@ import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-k import { Result, err, ok } from "@formbricks/types/error-handlers"; import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { + getReservedFutureDefaultAttributeKeyIssue, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage"; import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type"; import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact"; @@ -545,6 +549,22 @@ export const upsertBulkContacts = async ( }); } + const reservedNewKeys = attributeKeys.filter( + (key) => !existingKeySet.has(key) && isReservedFutureDefaultAttributeKey(key) + ); + + if (reservedNewKeys.length > 0) { + return err({ + type: "bad_request", + details: [ + { + field: "attributes", + issue: getReservedFutureDefaultAttributeKeyIssue(reservedNewKeys), + }, + ], + }); + } + // Type Detection Phase const attributeValuesByKey = buildAttributeValuesByKey(contacts); const attributeTypeMap = determineAttributeTypes(attributeValuesByKey, existingAttributeKeys); diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/tests/contact.test.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/tests/contact.test.ts index 6fcbb41f82..7d1fecdf54 100644 --- a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/tests/contact.test.ts +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/lib/tests/contact.test.ts @@ -347,6 +347,42 @@ describe("upsertBulkContacts", () => { expect(prisma.$executeRaw).toHaveBeenCalled(); }); + test("should return bad request when payload creates reserved future default keys", async () => { + const mockContacts = [ + { + attributes: [ + { attributeKey: { key: "email", name: "Email" }, value: "john@example.com" }, + { attributeKey: { key: "user_id", name: "User Id" }, value: "user-123" }, + ], + }, + ]; + + const mockParsedEmails = ["john@example.com"]; + + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([]); + vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]); + vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce([ + { id: "attr-key-email", key: "email", workspaceId: mockWorkspaceId, name: "Email" }, + ] as any); + + const result = await upsertBulkContacts(mockContacts, mockWorkspaceId, mockParsedEmails); + expect(result.ok).toBe(false); + expect(prisma.contact.createMany).not.toHaveBeenCalled(); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "bad_request", + details: [ + { + field: "attributes", + issue: + "Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.", + }, + ], + }); + } + }); + test("should update attribute key names when they change", async () => { // Mock data: a contact with an attribute that has a new name for an existing key const mockContacts = [ diff --git a/apps/web/modules/ee/contacts/attributes/actions.ts b/apps/web/modules/ee/contacts/attributes/actions.ts index 7bc9c01272..18dc7533ee 100644 --- a/apps/web/modules/ee/contacts/attributes/actions.ts +++ b/apps/web/modules/ee/contacts/attributes/actions.ts @@ -10,6 +10,10 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; +import { + RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { createContactAttributeKey, deleteContactAttributeKey, @@ -19,10 +23,15 @@ import { const ZCreateContactAttributeKeyAction = z.object({ workspaceId: ZId, - key: z.string().refine((val) => isSafeIdentifier(val), { - error: - "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter", - }), + key: z + .string() + .refine((val) => isSafeIdentifier(val), { + error: + "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter", + }) + .refine((val) => !isReservedFutureDefaultAttributeKey(val), { + error: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE, + }), name: z.string().optional(), description: z.string().optional(), dataType: ZContactAttributeDataType.optional(), diff --git a/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx b/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx index 07d75bf34f..ce82212ee8 100644 --- a/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx +++ b/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx @@ -8,6 +8,10 @@ import { useTranslation } from "react-i18next"; import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { formatSnakeCaseToTitleCase, isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier"; +import { + RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { Button } from "@/modules/ui/components/button"; import { Dialog, @@ -93,6 +97,14 @@ export function CreateAttributeModal({ workspaceId }: Readonly { } const columns = Object.keys(data[0]); - return (
-
- {columns.map((header, index) => ( - - {header.replace(/_/g, " ")} - - ))} -
- - {data.map((row, rowIndex) => ( -
- {columns.map((header, colIndex) => ( - - {row[header]} - + + + + {columns.map((header) => ( + + ))} + + + + {data.map((row, rowIndex) => ( + + {columns.map((header) => ( + + ))} + ))} - - ))} + +
+ {header} +
+ + {row[header] ?? ""} + +
); }; diff --git a/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx b/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx index bce9749d01..3f1f10eff0 100644 --- a/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx +++ b/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx @@ -4,6 +4,10 @@ import { ChevronDownIcon } from "lucide-react"; import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; +import { + RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { Button } from "@/modules/ui/components/button"; import { Command, @@ -41,6 +45,8 @@ export const UploadContactsAttributeCombobox = ({ currentKey, }: ITagsComboboxProps) => { const { t } = useTranslation(); + const normalizedSearchValue = searchValue.trim(); + useEffect(() => { // reset search value and value when closing the combobox if (!open) { @@ -50,20 +56,56 @@ export const UploadContactsAttributeCombobox = ({ // Check if the search value is a valid safe identifier for creating new attributes const isValidNewKey = useMemo(() => { - if (!searchValue) return false; - return isSafeIdentifier(searchValue.trim()); - }, [searchValue]); + if (!normalizedSearchValue) return false; + return isSafeIdentifier(normalizedSearchValue); + }, [normalizedSearchValue]); + + const isReservedNewKey = useMemo(() => { + if (!normalizedSearchValue) return false; + return isReservedFutureDefaultAttributeKey(normalizedSearchValue); + }, [normalizedSearchValue]); const existingKeyMatch = useMemo(() => { return keys.find((tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())); }, [keys, searchValue]); const handleCreateKey = () => { - if (isValidNewKey && !existingKeyMatch) { - createKey(searchValue.trim()); + if (isValidNewKey && !existingKeyMatch && !isReservedNewKey) { + createKey(normalizedSearchValue); } }; + const renderCreateOptionContent = () => { + if (isValidNewKey && !isReservedNewKey) { + return ( + + ); + } + + if (isReservedNewKey) { + return ( +
+ + {t("workspace.contacts.attribute_key_reserved_future_default", { + reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT, + })} + +
+ ); + } + + return ( +
+ {t("workspace.contacts.attribute_key_safe_identifier_required")} +
+ ); + }; + return ( @@ -135,22 +177,7 @@ export const UploadContactsAttributeCombobox = ({ ); })} {searchValue !== "" && !keys.some((tag) => tag.label === searchValue) && ( - - {isValidNewKey ? ( - - ) : ( -
- - {t("workspace.contacts.attribute_key_safe_identifier_required")} - -
- )} -
+ {renderCreateOptionContent()} )} 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 dd0388026c..ffc3e21fc9 100644 --- a/apps/web/modules/ee/contacts/components/upload-contacts-button.tsx +++ b/apps/web/modules/ee/contacts/components/upload-contacts-button.tsx @@ -13,6 +13,10 @@ import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions"; import { CsvTable } from "@/modules/ee/contacts/components/csv-table"; import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute"; +import { + RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact"; import { Alert } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; @@ -257,7 +261,6 @@ export const UploadContactsCSVButton = ({ useEffect(() => { const matches: Record = {}; - const invalidColumns: string[] = []; for (const columnName of csvColumns) { let matched = false; @@ -270,25 +273,66 @@ export const UploadContactsCSVButton = ({ } if (!matched) { - // This column will become a new attribute - validate it's a safe identifier - if (!isSafeIdentifier(columnName)) { - invalidColumns.push(columnName); - } matches[columnName] = columnName; } } setAttributeMap(matches); + }, [contactAttributeKeys, csvColumns]); + + useEffect(() => { + if (!csvColumns.length || !csvResponse.length) { + return; + } + + const invalidColumns: string[] = []; + const reservedColumns: string[] = []; + + for (const columnName of csvColumns) { + const mappedAttribute = attributeMap[columnName]; + if (!mappedAttribute) { + continue; + } + + const mapsToExistingAttribute = contactAttributeKeys.some( + (attributeKey) => attributeKey.id === mappedAttribute + ); + + if (mapsToExistingAttribute) { + continue; + } + + if (!isSafeIdentifier(mappedAttribute)) { + invalidColumns.push(columnName); + continue; + } + + if (isReservedFutureDefaultAttributeKey(mappedAttribute)) { + reservedColumns.push(columnName); + } + } + + const errorMessages: string[] = []; - // Show error for invalid column names that would become new attributes if (invalidColumns.length > 0) { - setError( + errorMessages.push( t("workspace.contacts.invalid_csv_column_names", { columns: invalidColumns.join(", "), }) ); } - }, [contactAttributeKeys, csvColumns, t]); + + if (reservedColumns.length > 0) { + errorMessages.push( + t("workspace.contacts.invalid_csv_reserved_column_names", { + columns: reservedColumns.join(", "), + reservedKeys: RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT, + }) + ); + } + + setError(errorMessages.join("\n")); + }, [attributeMap, contactAttributeKeys, csvColumns, csvResponse.length, t]); useEffect(() => { if (error && errorContainerRef.current) { @@ -304,7 +348,7 @@ export const UploadContactsCSVButton = ({ const exampleData = [ { email: "user1@example.com", - userId: "1001", + user_id: "1001", first_name: "John", last_name: "Doe", age: "28", @@ -313,7 +357,7 @@ export const UploadContactsCSVButton = ({ }, { email: "user2@example.com", - userId: "1002", + user_id: "1002", first_name: "Jane", last_name: "Smith", age: "34", @@ -322,7 +366,7 @@ export const UploadContactsCSVButton = ({ }, { email: "user3@example.com", - userId: "1003", + user_id: "1003", first_name: "Mark", last_name: "Jones", age: "45", @@ -331,7 +375,7 @@ export const UploadContactsCSVButton = ({ }, { email: "user4@example.com", - userId: "1004", + user_id: "1004", first_name: "Emily", last_name: "Brown", age: "22", @@ -340,7 +384,7 @@ export const UploadContactsCSVButton = ({ }, { email: "user5@example.com", - userId: "1005", + user_id: "1005", first_name: "David", last_name: "Wilson", age: "31", diff --git a/apps/web/modules/ee/contacts/lib/attribute-key-policy.ts b/apps/web/modules/ee/contacts/lib/attribute-key-policy.ts new file mode 100644 index 0000000000..d7e43f1c67 --- /dev/null +++ b/apps/web/modules/ee/contacts/lib/attribute-key-policy.ts @@ -0,0 +1,39 @@ +// Keep these keys reserved until the v5.1 migration moves default contact attributes +// from camelCase to safe identifiers with backward compatibility aliases. +// This is a preventive guardrail only (no schema/data migration in v5). +export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS = [ + "user_id", + "first_name", + "last_name", +] as const; + +export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT = + RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS.join(", "); + +export const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE = `Key is reserved for the v5.1 safe-identifier default attribute migration. Reserved keys: ${RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT}.`; + +const RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEY_SET: ReadonlySet = new Set( + RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS +); + +const normalizeKey = (key: string): string => key.trim().toLowerCase(); + +export const isReservedFutureDefaultAttributeKey = (key: string): boolean => { + return RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEY_SET.has(normalizeKey(key)); +}; + +export const getReservedFutureDefaultAttributeKeys = (keys: string[]): string[] => { + const normalized = keys + .map(normalizeKey) + .filter((key) => key.length > 0 && isReservedFutureDefaultAttributeKey(key)); + + return Array.from(new Set(normalized)); +}; + +export const getReservedFutureDefaultAttributeKeyIssue = (keys: string[]): string => { + const reservedKeys = getReservedFutureDefaultAttributeKeys(keys); + + return `Reserved attribute key(s): ${reservedKeys.join( + ", " + )}. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.`; +}; diff --git a/apps/web/modules/ee/contacts/lib/attributes.test.ts b/apps/web/modules/ee/contacts/lib/attributes.test.ts index 7579beedfb..e771e79e8a 100644 --- a/apps/web/modules/ee/contacts/lib/attributes.test.ts +++ b/apps/web/modules/ee/contacts/lib/attributes.test.ts @@ -221,6 +221,30 @@ describe("updateAttributes", () => { ); }); + test("skips creating reserved future default attributes", async () => { + vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); + vi.mocked(getContactAttributes).mockResolvedValue({ email: "jane@example.com" }); + vi.mocked(hasEmailAttribute).mockResolvedValue(false); + vi.mocked(hasUserIdAttribute).mockResolvedValue(false); + vi.mocked(prisma.$transaction).mockResolvedValue(undefined); + vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 }); + + const result = await updateAttributes(contactId, userId, workspaceId, { + email: "john@example.com", + user_id: "future-safe", + }); + + expect(result.success).toBe(true); + expect(result.errors).toContainEqual({ + code: "reserved_attribute_keys", + params: { + issue: + "Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.", + }, + }); + expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled(); + }); + test("returns success with only email attribute", async () => { vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" }); diff --git a/apps/web/modules/ee/contacts/lib/attributes.ts b/apps/web/modules/ee/contacts/lib/attributes.ts index 84e98ebd9b..440b01c7c1 100644 --- a/apps/web/modules/ee/contacts/lib/attributes.ts +++ b/apps/web/modules/ee/contacts/lib/attributes.ts @@ -6,6 +6,10 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants"; import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier"; import { validateInputs } from "@/lib/utils/validate"; +import { + getReservedFutureDefaultAttributeKeyIssue, + isReservedFutureDefaultAttributeKey, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { prepareNewSDKAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage"; import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; import { @@ -38,6 +42,7 @@ const MESSAGE_TEMPLATES: Record = { userid_already_exists: "The userId already exists for this environment and was not updated.", invalid_attribute_keys: "Skipped creating attribute(s) with invalid key(s): {keys}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.", + reserved_attribute_keys: "{issue}", attribute_limit_exceeded: "Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.", new_attribute_created: "Created new attribute '{key}' with type '{dataType}'", @@ -304,12 +309,15 @@ export const updateAttributes = async ( // Validate that new attribute keys are safe identifiers const validNewAttributes: typeof newAttributes = []; const invalidKeys: string[] = []; + const reservedKeys: string[] = []; for (const attr of newAttributes) { - if (isSafeIdentifier(attr.key)) { - validNewAttributes.push(attr); - } else { + if (!isSafeIdentifier(attr.key)) { invalidKeys.push(attr.key); + } else if (isReservedFutureDefaultAttributeKey(attr.key)) { + reservedKeys.push(attr.key); + } else { + validNewAttributes.push(attr); } } @@ -325,6 +333,17 @@ export const updateAttributes = async ( ); } + if (reservedKeys.length > 0) { + errors.push({ + code: "reserved_attribute_keys", + params: { issue: getReservedFutureDefaultAttributeKeyIssue(reservedKeys) }, + }); + logger.warn( + { workspaceId, reservedKeys }, + "SDK tried to create reserved future default attribute keys - skipping" + ); + } + if (validNewAttributes.length > 0) { const totalAttributeClassesLength = contactAttributeKeys.length + validNewAttributes.length; diff --git a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts index e4f8ccc223..361aa730c3 100644 --- a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts +++ b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts @@ -147,6 +147,13 @@ describe("createContactAttributeKey", () => { await expect(createContactAttributeKey({ workspaceId, key: "email" })).rejects.toThrow(InvalidInputError); }); + test("throws InvalidInputError when key is reserved for future defaults", async () => { + await expect(createContactAttributeKey({ workspaceId, key: "user_id" })).rejects.toThrow( + InvalidInputError + ); + expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled(); + }); + test("rethrows unknown prisma error codes", async () => { const err = Object.assign(new Error("Some prisma error"), { code: PrismaErrorType.RecordDoesNotExist }); vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(err); diff --git a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts index 8d46ad70ea..1722d0696c 100644 --- a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts +++ b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts @@ -4,6 +4,10 @@ import { PrismaErrorType } from "@formbricks/database/types/error"; import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier"; +import { + getReservedFutureDefaultAttributeKeyIssue, + isReservedFutureDefaultAttributeKey, +} from "./attribute-key-policy"; export const getContactAttributeKeys = reactCache( async (workspaceId: string): Promise => { @@ -31,6 +35,10 @@ export const createContactAttributeKey = async (data: { description?: string; dataType?: TContactAttributeDataType; }): Promise => { + if (isReservedFutureDefaultAttributeKey(data.key)) { + throw new InvalidInputError(getReservedFutureDefaultAttributeKeyIssue([data.key])); + } + try { const contactAttributeKey = await prisma.contactAttributeKey.create({ data: { diff --git a/apps/web/modules/ee/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/lib/contacts.test.ts index 8a1ec75d36..6ccec00f37 100644 --- a/apps/web/modules/ee/contacts/lib/contacts.test.ts +++ b/apps/web/modules/ee/contacts/lib/contacts.test.ts @@ -537,6 +537,22 @@ describe("Contacts Lib", () => { ).rejects.toThrow(ValidationError); }); + test("throws ValidationError when CSV creates reserved future default keys", async () => { + const reservedCsvData = [{ email: "john@example.com", user_id: "user-1" }]; + const attributeMap = { email: "email", user_id: "user_id" }; + + vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]); + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); + vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce([ + { key: "email", id: "key-1", dataType: "string" }, + ] as any); + + await expect( + createContactsFromCSV(reservedCsvData as any, mockWorkspaceId, "skip", attributeMap) + ).rejects.toThrow(ValidationError); + expect(prisma.contactAttributeKey.createMany).not.toHaveBeenCalled(); + }); + test("throws DatabaseError on Prisma error", async () => { const attributeMap = { email: "email" }; const prismaError = new Prisma.PrismaClientKnownRequestError("DB Error", { diff --git a/apps/web/modules/ee/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/lib/contacts.ts index 0842c1f2b7..05a8d8191e 100644 --- a/apps/web/modules/ee/contacts/lib/contacts.ts +++ b/apps/web/modules/ee/contacts/lib/contacts.ts @@ -9,6 +9,10 @@ import { DatabaseError, ValidationError } from "@formbricks/types/errors"; import { ITEMS_PER_PAGE } from "@/lib/constants"; import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier"; import { validateInputs } from "@/lib/utils/validate"; +import { + getReservedFutureDefaultAttributeKeyIssue, + getReservedFutureDefaultAttributeKeys, +} from "@/modules/ee/contacts/lib/attribute-key-policy"; import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage"; import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type"; @@ -412,6 +416,11 @@ const createMissingAttributeKeys = async ( ); } + const reservedKeys = getReservedFutureDefaultAttributeKeys(missingKeys); + if (reservedKeys.length > 0) { + throw new ValidationError(getReservedFutureDefaultAttributeKeyIssue(reservedKeys)); + } + // Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname" const uniqueMissingKeys = new Map(); for (const key of missingKeys) { diff --git a/apps/web/modules/ee/contacts/segments/actions.ts b/apps/web/modules/ee/contacts/segments/actions.ts index c06e53e136..4ace9586cb 100644 --- a/apps/web/modules/ee/contacts/segments/actions.ts +++ b/apps/web/modules/ee/contacts/segments/actions.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment"; import { getOrganization } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; @@ -49,7 +49,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); if (surveyWorkspaceId !== parsedInput.workspaceId) { - throw new Error("Survey and segment are not in the same workspace"); + throw new InvalidInputError("Survey and segment are not in the same workspace"); } } @@ -82,7 +82,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen if (!parsedFilters.success) { const errMsg = parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters"; - throw new Error(errMsg); + throw new InvalidInputError(errMsg); } const segment = await createSegment(parsedInput); @@ -139,7 +139,7 @@ export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdate if (!parsedFilters.success) { const errMsg = parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters"; - throw new Error(errMsg); + throw new InvalidInputError(errMsg); } await checkForRecursiveSegmentFilter(parsedFilters.data, parsedInput.segmentId); @@ -169,7 +169,7 @@ export const loadNewSegmentAction = authenticatedActionClient const segmentWorkspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId); if (surveyWorkspaceId !== segmentWorkspaceId) { - throw new Error("Segment and survey are not in the same workspace"); + throw new InvalidInputError("Segment and survey are not in the same workspace"); } const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); diff --git a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx index 8b02b4410d..80facf2a8f 100644 --- a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx +++ b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx @@ -184,22 +184,33 @@ export function AddFilterModal({ [t] ); + const contactAttributeKeysForPicker = useMemo(() => { + // `userId` is represented by the person filter (fingerprint icon), so hide it from attribute entries. + return contactAttributeKeys.filter((attributeKey) => attributeKey.key !== "userId"); + }, [contactAttributeKeys]); + const contactAttributeKeysFiltered = useMemo(() => { - if (!contactAttributeKeys) return []; + if (!contactAttributeKeysForPicker) return []; - if (!searchValue) return contactAttributeKeys; + if (!searchValue) return contactAttributeKeysForPicker; - return contactAttributeKeys.filter((attributeKey) => { + return contactAttributeKeysForPicker.filter((attributeKey) => { const attributeValueToSeach = attributeKey.name ?? attributeKey.key; return attributeValueToSeach.toLowerCase().includes(searchValue.toLowerCase()); }); - }, [contactAttributeKeys, searchValue]); + }, [contactAttributeKeysForPicker, searchValue]); const contactAttributeFiltered = useMemo(() => { - const contactAttributes = [{ name: "userId" }]; + const personIdentifiers = [{ id: "userId", label: t("common.user_id") }]; - return contactAttributes.filter((ca) => ca.name.toLowerCase().includes(searchValue.toLowerCase())); - }, [searchValue]); + return personIdentifiers.filter((personIdentifier) => { + const query = searchValue.toLowerCase(); + return ( + personIdentifier.id.toLowerCase().includes(query) || + personIdentifier.label.toLowerCase().includes(query) + ); + }); + }, [searchValue, t]); const segmentsFiltered = useMemo(() => { if (!segments) return []; @@ -283,10 +294,10 @@ export function AddFilterModal({ {filters.contactAttributeFiltered.map((personAttribute) => ( } - label={personAttribute.name} + label={personAttribute.label} onClick={() => { handleAddFilter({ type: "person", diff --git a/apps/web/modules/workspaces/settings/lib/workspace.ts b/apps/web/modules/workspaces/settings/lib/workspace.ts index 7e64497c64..553d90fe1c 100644 --- a/apps/web/modules/workspaces/settings/lib/workspace.ts +++ b/apps/web/modules/workspaces/settings/lib/workspace.ts @@ -9,6 +9,8 @@ import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formb import { validateInputs } from "@/lib/utils/validate"; import { deleteFilesByWorkspaceId } from "@/modules/storage/service"; +// Keep v5 defaults aligned with current production camelCase keys. +// Safe-identifier migration (with backwards compatibility) is intentionally deferred to v5.1. const DEFAULT_CONTACT_ATTRIBUTE_KEYS: Prisma.ContactAttributeKeyCreateWithoutWorkspaceInput[] = [ { key: "userId", @@ -151,7 +153,7 @@ export const deleteWorkspace = async (workspaceId: string): Promise if (workspace) { const s3Result = await deleteFilesByWorkspaceId(workspaceId, []); - if (!s3Result.ok) { + if (!s3Result.ok && "error" in s3Result) { // fail silently because we don't want to throw an error if the files are not deleted logger.error(s3Result.error, "Error deleting S3 files"); } diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index d1fa162f99..ddce33bd7d 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -449,6 +449,8 @@ async function main(): Promise { }, }); + // Keep seed defaults aligned with production v5 camelCase keys. + // Safe-identifier migration is deferred to v5.1. // Contact attribute keys for the workspace const defaultAttributeKeys = [ { name: "Email", key: "email", isUnique: true, type: "default" as const },