mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-12 18:59:38 -06:00
fixes feedback
This commit is contained in:
@@ -30,7 +30,10 @@ export const POST = async (request: Request) => {
|
||||
}
|
||||
|
||||
const jsonInput = await request.json();
|
||||
const convertedJsonInput = convertDatesInObject(jsonInput);
|
||||
const convertedJsonInput = convertDatesInObject(
|
||||
jsonInput,
|
||||
new Set(["contactAttributes", "variables", "data", "meta"])
|
||||
);
|
||||
|
||||
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
|
||||
|
||||
|
||||
@@ -598,6 +598,12 @@ checksums:
|
||||
environments/contacts/attribute_updated_successfully: 0e64422156c29940cd4dab2f9d1f40b2
|
||||
environments/contacts/attribute_value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
environments/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
|
||||
environments/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
|
||||
environments/contacts/attributes_msg_attribute_type_validation_error: ed177ce83bd174ed6be7e889664f93a1
|
||||
environments/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
|
||||
environments/contacts/attributes_msg_email_or_userid_required: 3be0e745cd3500c9a23bad2e25ad3147
|
||||
environments/contacts/attributes_msg_new_attribute_created: c4c7b27523058f43b70411d7aa6510e5
|
||||
environments/contacts/attributes_msg_userid_already_exists: d2d95ece4b06507be18c9ba240b0a26b
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
@@ -642,6 +648,12 @@ checksums:
|
||||
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
environments/contacts/upload_contacts_error_attribute_type_mismatch: 70a60f0886ce767c00defa7d4aad0f93
|
||||
environments/contacts/upload_contacts_error_duplicate_mappings: 9c1e1f07e476226bad98ccfa07979fec
|
||||
environments/contacts/upload_contacts_error_file_too_large: 0c1837286c55d18049277465bc2444c1
|
||||
environments/contacts/upload_contacts_error_generic: 3a8d35a421b377198361af9972392693
|
||||
environments/contacts/upload_contacts_error_invalid_file_type: 15ef4fa7c2d5273b05a042f398655e81
|
||||
environments/contacts/upload_contacts_error_no_valid_contacts: 27fbd24ed2d2fa3b6ed7b3a8c1dad343
|
||||
environments/contacts/upload_contacts_modal_attribute_header: 263246ad2a76f8e2f80f0ed175d7629a
|
||||
environments/contacts/upload_contacts_modal_attributes_description: e2cedbd4a043423002cbb2048e2145ac
|
||||
environments/contacts/upload_contacts_modal_attributes_new: 9829382598c681de74130440a37b560f
|
||||
@@ -814,6 +826,40 @@ checksums:
|
||||
environments/segments/no_attributes_yet: 57beecc917dcd598ccdd0ccfb364a960
|
||||
environments/segments/no_filters_yet: d885a68516840e15dd27f1c17d9a8975
|
||||
environments/segments/no_segments_yet: 6307a4163a5bd553bb2aba074d24be9c
|
||||
environments/segments/operator_contains: 06dd606c0a8f81f9a03b414e9ae89440
|
||||
environments/segments/operator_does_not_contain: 854da2bdf10613ce62fb454bab16c58b
|
||||
environments/segments/operator_ends_with: 2bd866369766c6a2ef74bb9fa74b1d7e
|
||||
environments/segments/operator_is_after: f9d9296eb9a5a7d168cc4e65a4095a87
|
||||
environments/segments/operator_is_before: 2462480cf4e8d2832b64004fbd463e55
|
||||
environments/segments/operator_is_between: 41ff45044d8a017a8a74f72be57916b8
|
||||
environments/segments/operator_is_newer_than: c41e03366623caed6b2c224e50387614
|
||||
environments/segments/operator_is_not_set: 906801489132487ef457652af4835142
|
||||
environments/segments/operator_is_older_than: acca6b309da507bbc5973c4b56b698b0
|
||||
environments/segments/operator_is_same_day: c06506b6eb9f6491f15685baccd68897
|
||||
environments/segments/operator_is_set: 9850468156356f95884bbaf56b6687aa
|
||||
environments/segments/operator_starts_with: 37e55e9080c84a1855956161e7885c21
|
||||
environments/segments/operator_title_contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/segments/operator_title_does_not_contain: d618eb0f854f7efa0d7c644e6628fa42
|
||||
environments/segments/operator_title_ends_with: c8a5f60f1bd1d8fa018dbbf49806fb5b
|
||||
environments/segments/operator_title_equals: 73439e2839b8049e68079b1b6f2e3c41
|
||||
environments/segments/operator_title_greater_equal: 556b342cee0ac7055171e41be80f49e4
|
||||
environments/segments/operator_title_greater_than: e06dabbbf3a9c527502c997101edab40
|
||||
environments/segments/operator_title_is_after: bd4cf644e442fca330cb483528485e5f
|
||||
environments/segments/operator_title_is_before: a47ce3825c5c7cea7ed7eb8d5505a2d5
|
||||
environments/segments/operator_title_is_between: 5721c877c60f0005dc4ce78d4c0d3fdc
|
||||
environments/segments/operator_title_is_newer_than: 133731671413c702a55cdfb9134d63f8
|
||||
environments/segments/operator_title_is_not_set: c1a6fd89387686d3a5426a768bb286e9
|
||||
environments/segments/operator_title_is_older_than: 9064cd482f2312c8b10aee4937d0278d
|
||||
environments/segments/operator_title_is_same_day: 9340bf7bd6ab504d71b0e957ca9fcf4c
|
||||
environments/segments/operator_title_is_set: 1c66019bd162201db83aef305ab2a161
|
||||
environments/segments/operator_title_less_equal: 235dbef60cd0af5ff1d319aab24a1109
|
||||
environments/segments/operator_title_less_than: e9f3c9742143760b28bf4e326f63a97b
|
||||
environments/segments/operator_title_not_equals: a186482f46739c9fe8683826a1cab723
|
||||
environments/segments/operator_title_starts_with: f6673c17475708313c6a0f245b561781
|
||||
environments/segments/operator_title_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
|
||||
environments/segments/operator_title_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
|
||||
environments/segments/operator_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
|
||||
environments/segments/operator_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
|
||||
environments/segments/person_and_attributes: 507023d577326a6326dd9603dcdc589d
|
||||
environments/segments/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||
environments/segments/please_remove_the_segment_from_these_surveys_in_order_to_delete_it: 1858a8ae40bed3a8c06c3bb518e0b8aa
|
||||
|
||||
@@ -142,7 +142,8 @@ describe("Time Utilities", () => {
|
||||
expect(convertDatesInObject(123)).toBe(123);
|
||||
});
|
||||
|
||||
test("should not convert dates in contactAttributes", () => {
|
||||
test("should not convert dates in ignored keys when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
@@ -151,13 +152,14 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.contactAttributes.email).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("should not convert dates in variables", () => {
|
||||
test("should not convert dates in variables when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
updatedAt: "2024-03-20T15:30:00",
|
||||
variables: {
|
||||
@@ -166,13 +168,14 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.variables.userId).toBe("123");
|
||||
});
|
||||
|
||||
test("should not convert dates in data or meta", () => {
|
||||
test("should not convert dates in data or meta when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
data: {
|
||||
@@ -183,10 +186,23 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
|
||||
});
|
||||
|
||||
test("should recurse into all keys when keysToIgnore is not provided", () => {
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,18 +151,17 @@ export const getTodaysDateTimeFormatted = (seperator: string) => {
|
||||
return [formattedDate, formattedTime].join(seperator);
|
||||
};
|
||||
|
||||
export const convertDatesInObject = <T>(obj: T): T => {
|
||||
export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T => {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj; // Return if obj is not an object
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
// Handle arrays by mapping each element through the function
|
||||
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
|
||||
return obj.map((item) => convertDatesInObject(item, keysToIgnore)) as unknown as T;
|
||||
}
|
||||
const newObj: any = {};
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in obj) {
|
||||
if (keysToIgnore.has(key)) {
|
||||
if (keysToIgnore?.has(key)) {
|
||||
newObj[key] = obj[key];
|
||||
continue;
|
||||
}
|
||||
@@ -173,10 +172,10 @@ export const convertDatesInObject = <T>(obj: T): T => {
|
||||
) {
|
||||
newObj[key] = new Date(obj[key] as unknown as string);
|
||||
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
newObj[key] = convertDatesInObject(obj[key]);
|
||||
newObj[key] = convertDatesInObject(obj[key], keysToIgnore);
|
||||
} else {
|
||||
newObj[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
return newObj as T;
|
||||
};
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribut erfolgreich aktualisiert",
|
||||
"attribute_value": "Wert",
|
||||
"attribute_value_placeholder": "Attributwert",
|
||||
"attributes_msg_attribute_limit_exceeded": "Es konnten {count} neue Attribute nicht erstellt werden, da dies das maximale Limit von {limit} Attributklassen überschreiten würde. Bestehende Attribute wurden erfolgreich aktualisiert.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (Attribut '{key}' hat dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Die E-Mail existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder userId ist erforderlich. Die bestehenden Werte wurden beibehalten.",
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut '{key}' mit Typ '{dataType}' erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die userId existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Systemattribute",
|
||||
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
|
||||
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribut \"{key}\" ist als \"{dataType}\" definiert, aber die CSV-Datei enthält ungültige Werte: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Doppelte Zuordnungen für folgende Attribute gefunden: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Dateigröße überschreitet das maximale Limit von 800KB",
|
||||
"upload_contacts_error_generic": "Beim Hochladen der Kontakte ist ein Fehler aufgetreten. Bitte versuche es später erneut.",
|
||||
"upload_contacts_error_invalid_file_type": "Bitte lade eine CSV-Datei hoch",
|
||||
"upload_contacts_error_no_valid_contacts": "Die hochgeladene CSV-Datei enthält keine gültigen Kontakte. Bitte schaue dir die Beispiel-CSV-Datei für das richtige Format an.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks-Attribut",
|
||||
"upload_contacts_modal_attributes_description": "Ordne die Spalten in deiner CSV den Attributen in Formbricks zu.",
|
||||
"upload_contacts_modal_attributes_new": "Neues Attribut",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Noch keine Attribute",
|
||||
"no_filters_yet": "Es gibt noch keine Filter",
|
||||
"no_segments_yet": "Du hast momentan keine gespeicherten Segmente.",
|
||||
"operator_contains": "enthält",
|
||||
"operator_does_not_contain": "enthält nicht",
|
||||
"operator_ends_with": "endet mit",
|
||||
"operator_is_after": "ist nach",
|
||||
"operator_is_before": "ist vor",
|
||||
"operator_is_between": "ist zwischen",
|
||||
"operator_is_newer_than": "ist neuer als",
|
||||
"operator_is_not_set": "ist nicht festgelegt",
|
||||
"operator_is_older_than": "ist älter als",
|
||||
"operator_is_same_day": "ist am selben Tag",
|
||||
"operator_is_set": "ist festgelegt",
|
||||
"operator_starts_with": "fängt an mit",
|
||||
"operator_title_contains": "Enthält",
|
||||
"operator_title_does_not_contain": "Enthält nicht",
|
||||
"operator_title_ends_with": "Endet mit",
|
||||
"operator_title_equals": "Gleich",
|
||||
"operator_title_greater_equal": "Größer als oder gleich",
|
||||
"operator_title_greater_than": "Größer als",
|
||||
"operator_title_is_after": "Ist nach",
|
||||
"operator_title_is_before": "Ist vor",
|
||||
"operator_title_is_between": "Ist zwischen",
|
||||
"operator_title_is_newer_than": "Ist neuer als",
|
||||
"operator_title_is_not_set": "Ist nicht festgelegt",
|
||||
"operator_title_is_older_than": "Ist älter als",
|
||||
"operator_title_is_same_day": "Ist am selben Tag",
|
||||
"operator_title_is_set": "Ist festgelegt",
|
||||
"operator_title_less_equal": "Kleiner oder gleich",
|
||||
"operator_title_less_than": "Kleiner als",
|
||||
"operator_title_not_equals": "Ist nicht gleich",
|
||||
"operator_title_starts_with": "Fängt an mit",
|
||||
"operator_title_user_is_in": "Nutzer ist in",
|
||||
"operator_title_user_is_not_in": "Nutzer ist nicht in",
|
||||
"operator_user_is_in": "Nutzer ist in",
|
||||
"operator_user_is_not_in": "Nutzer ist nicht in",
|
||||
"person_and_attributes": "Person & Attribute",
|
||||
"phone": "Handy",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Bitte entferne das Segment aus diesen Umfragen, um es zu löschen.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribute updated successfully",
|
||||
"attribute_value": "Value",
|
||||
"attribute_value_placeholder": "Attribute Value",
|
||||
"attributes_msg_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.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (attribute '{key}' has dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "The email already exists for this environment and was not updated.",
|
||||
"attributes_msg_email_or_userid_required": "Either email or userId is required. The existing values were preserved.",
|
||||
"attributes_msg_new_attribute_created": "Created new attribute '{key}' with type '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "The userId already exists for this environment and was not updated.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "System Attributes",
|
||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribute \"{key}\" is typed as \"{dataType}\" but CSV contains invalid values: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Duplicate mappings found for the following attributes: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "File size exceeds the maximum limit of 800KB",
|
||||
"upload_contacts_error_generic": "An error occurred while uploading the contacts. Please try again later.",
|
||||
"upload_contacts_error_invalid_file_type": "Please upload a CSV file",
|
||||
"upload_contacts_error_no_valid_contacts": "The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks Attribute",
|
||||
"upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "New attribute",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "No attributes yet!",
|
||||
"no_filters_yet": "There are no filters yet!",
|
||||
"no_segments_yet": "You currently have no saved segments.",
|
||||
"operator_contains": "contains",
|
||||
"operator_does_not_contain": "does not contain",
|
||||
"operator_ends_with": "ends with",
|
||||
"operator_is_after": "is after",
|
||||
"operator_is_before": "is before",
|
||||
"operator_is_between": "is between",
|
||||
"operator_is_newer_than": "is newer than",
|
||||
"operator_is_not_set": "is not set",
|
||||
"operator_is_older_than": "is older than",
|
||||
"operator_is_same_day": "is same day",
|
||||
"operator_is_set": "is set",
|
||||
"operator_starts_with": "starts with",
|
||||
"operator_title_contains": "Contains",
|
||||
"operator_title_does_not_contain": "Does not contain",
|
||||
"operator_title_ends_with": "Ends with",
|
||||
"operator_title_equals": "Equals",
|
||||
"operator_title_greater_equal": "Greater than or equal to",
|
||||
"operator_title_greater_than": "Greater than",
|
||||
"operator_title_is_after": "Is after",
|
||||
"operator_title_is_before": "Is before",
|
||||
"operator_title_is_between": "Is between",
|
||||
"operator_title_is_newer_than": "Is newer than",
|
||||
"operator_title_is_not_set": "Is not set",
|
||||
"operator_title_is_older_than": "Is older than",
|
||||
"operator_title_is_same_day": "Is same day",
|
||||
"operator_title_is_set": "Is set",
|
||||
"operator_title_less_equal": "Less than or equal to",
|
||||
"operator_title_less_than": "Less than",
|
||||
"operator_title_not_equals": "Not equals to",
|
||||
"operator_title_starts_with": "Starts with",
|
||||
"operator_title_user_is_in": "User is in",
|
||||
"operator_title_user_is_not_in": "User is not in",
|
||||
"operator_user_is_in": "User is in",
|
||||
"operator_user_is_not_in": "User is not in",
|
||||
"person_and_attributes": "Person & Attributes",
|
||||
"phone": "Phone",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Please remove the segment from these surveys in order to delete it.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atributo actualizado con éxito",
|
||||
"attribute_value": "Valor",
|
||||
"attribute_value_placeholder": "Valor del atributo",
|
||||
"attributes_msg_attribute_limit_exceeded": "No se pudieron crear {count} atributo(s) nuevo(s) ya que se excedería el límite máximo de {limit} clases de atributos. Los atributos existentes se actualizaron correctamente.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (el atributo '{key}' tiene dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "El email ya existe para este entorno y no se actualizó.",
|
||||
"attributes_msg_email_or_userid_required": "Se requiere email o userId. Se conservaron los valores existentes.",
|
||||
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo '{key}' con tipo '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "El userId ya existe para este entorno y no se actualizó.",
|
||||
"contact_deleted_successfully": "Contacto eliminado correctamente",
|
||||
"contact_not_found": "No se ha encontrado dicho contacto",
|
||||
"contacts_table_refresh": "Actualizar contactos",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Atributos del sistema",
|
||||
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
|
||||
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
|
||||
"upload_contacts_error_attribute_type_mismatch": "El atributo \"{key}\" está tipado como \"{dataType}\" pero el CSV contiene valores no válidos: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Se encontraron mapeos duplicados para los siguientes atributos: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "El tamaño del archivo supera el límite máximo de 800 KB",
|
||||
"upload_contacts_error_generic": "Se produjo un error al cargar los contactos. Por favor, inténtalo de nuevo más tarde.",
|
||||
"upload_contacts_error_invalid_file_type": "Por favor, carga un archivo CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "El archivo CSV cargado no contiene ningún contacto válido, por favor consulta el archivo CSV de ejemplo para ver el formato correcto.",
|
||||
"upload_contacts_modal_attribute_header": "Atributo de Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Asigna las columnas de tu CSV a los atributos en Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nuevo atributo",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "¡Aún no hay atributos!",
|
||||
"no_filters_yet": "¡Aún no hay filtros!",
|
||||
"no_segments_yet": "Actualmente no tienes segmentos guardados.",
|
||||
"operator_contains": "contiene",
|
||||
"operator_does_not_contain": "no contiene",
|
||||
"operator_ends_with": "termina con",
|
||||
"operator_is_after": "es después de",
|
||||
"operator_is_before": "es antes de",
|
||||
"operator_is_between": "está entre",
|
||||
"operator_is_newer_than": "es más reciente que",
|
||||
"operator_is_not_set": "no está establecido",
|
||||
"operator_is_older_than": "es más antiguo que",
|
||||
"operator_is_same_day": "es el mismo día",
|
||||
"operator_is_set": "está establecido",
|
||||
"operator_starts_with": "comienza con",
|
||||
"operator_title_contains": "Contiene",
|
||||
"operator_title_does_not_contain": "No contiene",
|
||||
"operator_title_ends_with": "Termina con",
|
||||
"operator_title_equals": "Es igual a",
|
||||
"operator_title_greater_equal": "Mayor o igual que",
|
||||
"operator_title_greater_than": "Mayor que",
|
||||
"operator_title_is_after": "Es después de",
|
||||
"operator_title_is_before": "Es antes de",
|
||||
"operator_title_is_between": "Está entre",
|
||||
"operator_title_is_newer_than": "Es más reciente que",
|
||||
"operator_title_is_not_set": "No está establecido",
|
||||
"operator_title_is_older_than": "Es más antiguo que",
|
||||
"operator_title_is_same_day": "Es el mismo día",
|
||||
"operator_title_is_set": "Está establecido",
|
||||
"operator_title_less_equal": "Menor o igual que",
|
||||
"operator_title_less_than": "Menor que",
|
||||
"operator_title_not_equals": "No es igual a",
|
||||
"operator_title_starts_with": "Comienza con",
|
||||
"operator_title_user_is_in": "El usuario está en",
|
||||
"operator_title_user_is_not_in": "El usuario no está en",
|
||||
"operator_user_is_in": "El usuario está en",
|
||||
"operator_user_is_not_in": "El usuario no está en",
|
||||
"person_and_attributes": "Persona y atributos",
|
||||
"phone": "Teléfono",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, elimina el segmento de estas encuestas para poder borrarlo.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribut mis à jour avec succès",
|
||||
"attribute_value": "Valeur",
|
||||
"attribute_value_placeholder": "Valeur d'attribut",
|
||||
"attributes_msg_attribute_limit_exceeded": "Impossible de créer {count, plural, one {# nouvel attribut} other {# nouveaux attributs}} car cela dépasserait la limite maximale de {limit} classes d'attributs. Les attributs existants ont été mis à jour avec succès.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (l'attribut « {key} » a le type de données : {dataType})",
|
||||
"attributes_msg_email_already_exists": "L'adresse e-mail existe déjà pour cet environnement et n'a pas été mise à jour.",
|
||||
"attributes_msg_email_or_userid_required": "L'adresse e-mail ou l'identifiant utilisateur est requis. Les valeurs existantes ont été conservées.",
|
||||
"attributes_msg_new_attribute_created": "Nouvel attribut « {key} » créé avec le type « {dataType} »",
|
||||
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"contact_not_found": "Aucun contact trouvé",
|
||||
"contacts_table_refresh": "Actualiser les contacts",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Attributs système",
|
||||
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
|
||||
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
|
||||
"upload_contacts_error_attribute_type_mismatch": "L'attribut « {key} » est de type « {dataType} » mais le CSV contient des valeurs invalides : {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Mappages en double trouvés pour les attributs suivants : {attributes}",
|
||||
"upload_contacts_error_file_too_large": "La taille du fichier dépasse la limite maximale de 800 Ko",
|
||||
"upload_contacts_error_generic": "Une erreur s'est produite lors de l'importation des contacts. Veuillez réessayer plus tard.",
|
||||
"upload_contacts_error_invalid_file_type": "Veuillez importer un fichier CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "Le fichier CSV importé ne contient aucun contact valide, veuillez consulter l'exemple de fichier CSV pour le format correct.",
|
||||
"upload_contacts_modal_attribute_header": "Attribut Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mappez les colonnes de votre CSV aux attributs dans Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nouvel attribut",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Aucun attribut pour le moment !",
|
||||
"no_filters_yet": "Il n'y a pas encore de filtres !",
|
||||
"no_segments_yet": "Aucun segment n'est actuellement enregistré.",
|
||||
"operator_contains": "contient",
|
||||
"operator_does_not_contain": "ne contient pas",
|
||||
"operator_ends_with": "se termine par",
|
||||
"operator_is_after": "est après",
|
||||
"operator_is_before": "est avant",
|
||||
"operator_is_between": "est entre",
|
||||
"operator_is_newer_than": "est plus récent que",
|
||||
"operator_is_not_set": "n'est pas défini",
|
||||
"operator_is_older_than": "est plus ancien que",
|
||||
"operator_is_same_day": "est le même jour",
|
||||
"operator_is_set": "est défini",
|
||||
"operator_starts_with": "commence par",
|
||||
"operator_title_contains": "Contient",
|
||||
"operator_title_does_not_contain": "Ne contient pas",
|
||||
"operator_title_ends_with": "Se termine par",
|
||||
"operator_title_equals": "Égal",
|
||||
"operator_title_greater_equal": "Supérieur ou égal à",
|
||||
"operator_title_greater_than": "Supérieur à",
|
||||
"operator_title_is_after": "Est après",
|
||||
"operator_title_is_before": "Est avant",
|
||||
"operator_title_is_between": "Est entre",
|
||||
"operator_title_is_newer_than": "Est plus récent que",
|
||||
"operator_title_is_not_set": "N'est pas défini",
|
||||
"operator_title_is_older_than": "Est plus ancien que",
|
||||
"operator_title_is_same_day": "Est le même jour",
|
||||
"operator_title_is_set": "Est défini",
|
||||
"operator_title_less_equal": "Inférieur ou égal à",
|
||||
"operator_title_less_than": "Inférieur à",
|
||||
"operator_title_not_equals": "N'est pas égal à",
|
||||
"operator_title_starts_with": "Commence par",
|
||||
"operator_title_user_is_in": "L'utilisateur est dans",
|
||||
"operator_title_user_is_not_in": "L'utilisateur n'est pas dans",
|
||||
"operator_user_is_in": "L'utilisateur est dans",
|
||||
"operator_user_is_not_in": "L'utilisateur n'est pas dans",
|
||||
"person_and_attributes": "Personne et attributs",
|
||||
"phone": "Téléphone",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Veuillez supprimer le segment de ces enquêtes afin de le supprimer.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Az attribútum sikeresen frissítve",
|
||||
"attribute_value": "Érték",
|
||||
"attribute_value_placeholder": "Attribútum értéke",
|
||||
"attributes_msg_attribute_limit_exceeded": "Nem sikerült létrehozni {count} új attribútumot, mivel az meghaladná a maximális {limit} attribútumosztály-korlátot. A meglévő attribútumok sikeresen frissítve lettek.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (a(z) '{key}' attribútum adattípusa: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Az e-mail cím már létezik ebben a környezetben, és nem lett frissítve.",
|
||||
"attributes_msg_email_or_userid_required": "E-mail cím vagy felhasználói azonosító megadása kötelező. A meglévő értékek megmaradtak.",
|
||||
"attributes_msg_new_attribute_created": "Új '{key}' attribútum létrehozva '{dataType}' típussal",
|
||||
"attributes_msg_userid_already_exists": "A felhasználói azonosító már létezik ebben a környezetben, és nem lett frissítve.",
|
||||
"contact_deleted_successfully": "A partner sikeresen törölve",
|
||||
"contact_not_found": "Nem található ilyen partner",
|
||||
"contacts_table_refresh": "Partnerek frissítése",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Rendszer attribútumok",
|
||||
"unlock_contacts_description": "Partnerek kezelése és célzott kérdőívek kiküldése",
|
||||
"unlock_contacts_title": "Partnerek feloldása egy magasabb csomaggal",
|
||||
"upload_contacts_error_attribute_type_mismatch": "A(z) \"{key}\" attribútum típusa \"{dataType}\", de a CSV érvénytelen értékeket tartalmaz: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Duplikált leképezések találhatók a következő attribútumokhoz: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "A fájl mérete meghaladja a maximális 800KB-os limitet",
|
||||
"upload_contacts_error_generic": "Hiba történt a kapcsolatok feltöltése során. Kérjük, próbáld újra később.",
|
||||
"upload_contacts_error_invalid_file_type": "Kérjük, tölts fel egy CSV fájlt",
|
||||
"upload_contacts_error_no_valid_contacts": "A feltöltött CSV fájl nem tartalmaz érvényes kapcsolatokat, kérjük, nézd meg a minta CSV fájlt a helyes formátumhoz.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks attribútum",
|
||||
"upload_contacts_modal_attributes_description": "A CSV-ben lévő oszlopok leképezése a Formbricksben lévő attribútumokra.",
|
||||
"upload_contacts_modal_attributes_new": "Új attribútum",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Még nincsenek attribútumok!",
|
||||
"no_filters_yet": "Még nincsenek szűrők!",
|
||||
"no_segments_yet": "Jelenleg nincsenek mentett szakaszai.",
|
||||
"operator_contains": "tartalmazza",
|
||||
"operator_does_not_contain": "nem tartalmazza",
|
||||
"operator_ends_with": "ezzel végződik",
|
||||
"operator_is_after": "ez után",
|
||||
"operator_is_before": "ez előtt",
|
||||
"operator_is_between": "között",
|
||||
"operator_is_newer_than": "újabb mint",
|
||||
"operator_is_not_set": "nincs beállítva",
|
||||
"operator_is_older_than": "régebbi mint",
|
||||
"operator_is_same_day": "ugyanazon a napon",
|
||||
"operator_is_set": "beállítva",
|
||||
"operator_starts_with": "ezzel kezdődik",
|
||||
"operator_title_contains": "Tartalmazza",
|
||||
"operator_title_does_not_contain": "Nem tartalmazza",
|
||||
"operator_title_ends_with": "Ezzel végződik",
|
||||
"operator_title_equals": "Egyenlő",
|
||||
"operator_title_greater_equal": "Nagyobb vagy egyenlő",
|
||||
"operator_title_greater_than": "Nagyobb mint",
|
||||
"operator_title_is_after": "Ez után",
|
||||
"operator_title_is_before": "Ez előtt",
|
||||
"operator_title_is_between": "Között",
|
||||
"operator_title_is_newer_than": "Újabb mint",
|
||||
"operator_title_is_not_set": "Nincs beállítva",
|
||||
"operator_title_is_older_than": "Régebbi mint",
|
||||
"operator_title_is_same_day": "Ugyanazon a napon",
|
||||
"operator_title_is_set": "Beállítva",
|
||||
"operator_title_less_equal": "Kisebb vagy egyenlő",
|
||||
"operator_title_less_than": "Kisebb mint",
|
||||
"operator_title_not_equals": "Nem egyenlő",
|
||||
"operator_title_starts_with": "Ezzel kezdődik",
|
||||
"operator_title_user_is_in": "A felhasználó benne van",
|
||||
"operator_title_user_is_not_in": "A felhasználó nincs benne",
|
||||
"operator_user_is_in": "A felhasználó benne van",
|
||||
"operator_user_is_not_in": "A felhasználó nincs benne",
|
||||
"person_and_attributes": "Személy és attribútumok",
|
||||
"phone": "Telefon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Távolítsa el a szakaszt ezekből a kérdőívekből, hogy törölhesse azt.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "属性を更新しました",
|
||||
"attribute_value": "値",
|
||||
"attribute_value_placeholder": "属性値",
|
||||
"attributes_msg_attribute_limit_exceeded": "最大制限の{limit}個の属性クラスを超えるため、{count}個の新しい属性を作成できませんでした。既存の属性は正常に更新されました。",
|
||||
"attributes_msg_attribute_type_validation_error": "{error}(属性'{key}'のデータ型: {dataType})",
|
||||
"attributes_msg_email_already_exists": "このメールアドレスはこの環境に既に存在するため、更新されませんでした。",
|
||||
"attributes_msg_email_or_userid_required": "メールアドレスまたはユーザーIDのいずれかが必要です。既存の値は保持されました。",
|
||||
"attributes_msg_new_attribute_created": "新しい属性'{key}'をタイプ'{dataType}'で作成しました",
|
||||
"attributes_msg_userid_already_exists": "このユーザーIDはこの環境に既に存在するため、更新されませんでした。",
|
||||
"contact_deleted_successfully": "連絡先を正常に削除しました",
|
||||
"contact_not_found": "そのような連絡先は見つかりません",
|
||||
"contacts_table_refresh": "連絡先を更新",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "システム属性",
|
||||
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
|
||||
"unlock_contacts_title": "上位プランで連絡先をアンロック",
|
||||
"upload_contacts_error_attribute_type_mismatch": "属性「{key}」は「{dataType}」として型付けされていますが、CSVに無効な値が含まれています:{values}",
|
||||
"upload_contacts_error_duplicate_mappings": "次の属性に重複したマッピングが見つかりました:{attributes}",
|
||||
"upload_contacts_error_file_too_large": "ファイルサイズが最大制限の800KBを超えています",
|
||||
"upload_contacts_error_generic": "連絡先のアップロード中にエラーが発生しました。後でもう一度お試しください。",
|
||||
"upload_contacts_error_invalid_file_type": "CSVファイルをアップロードしてください",
|
||||
"upload_contacts_error_no_valid_contacts": "アップロードされたCSVファイルには有効な連絡先が含まれていません。正しい形式についてはサンプルCSVファイルをご確認ください。",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks属性",
|
||||
"upload_contacts_modal_attributes_description": "CSVの列をFormbricksの属性にマッピングします。",
|
||||
"upload_contacts_modal_attributes_new": "新しい属性",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "属性がまだありません!",
|
||||
"no_filters_yet": "フィルターはまだありません!",
|
||||
"no_segments_yet": "保存されたセグメントはまだありません。",
|
||||
"operator_contains": "を含む",
|
||||
"operator_does_not_contain": "を含まない",
|
||||
"operator_ends_with": "で終わる",
|
||||
"operator_is_after": "より後",
|
||||
"operator_is_before": "より前",
|
||||
"operator_is_between": "の間である",
|
||||
"operator_is_newer_than": "より新しい",
|
||||
"operator_is_not_set": "設定されていない",
|
||||
"operator_is_older_than": "より古い",
|
||||
"operator_is_same_day": "同じ日である",
|
||||
"operator_is_set": "設定されている",
|
||||
"operator_starts_with": "で始まる",
|
||||
"operator_title_contains": "を含む",
|
||||
"operator_title_does_not_contain": "を含まない",
|
||||
"operator_title_ends_with": "で終わる",
|
||||
"operator_title_equals": "と等しい",
|
||||
"operator_title_greater_equal": "以上",
|
||||
"operator_title_greater_than": "より大きい",
|
||||
"operator_title_is_after": "より後",
|
||||
"operator_title_is_before": "より前",
|
||||
"operator_title_is_between": "の間である",
|
||||
"operator_title_is_newer_than": "より新しい",
|
||||
"operator_title_is_not_set": "設定されていない",
|
||||
"operator_title_is_older_than": "より古い",
|
||||
"operator_title_is_same_day": "同じ日である",
|
||||
"operator_title_is_set": "設定されている",
|
||||
"operator_title_less_equal": "以下",
|
||||
"operator_title_less_than": "より小さい",
|
||||
"operator_title_not_equals": "等しくない",
|
||||
"operator_title_starts_with": "で始まる",
|
||||
"operator_title_user_is_in": "ユーザーが含まれる",
|
||||
"operator_title_user_is_not_in": "ユーザーが含まれない",
|
||||
"operator_user_is_in": "ユーザーが含まれる",
|
||||
"operator_user_is_not_in": "ユーザーが含まれない",
|
||||
"person_and_attributes": "人物と属性",
|
||||
"phone": "電話",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "このセグメントを削除するには、まず以下のフォームから外してください。",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribuut succesvol bijgewerkt",
|
||||
"attribute_value": "Waarde",
|
||||
"attribute_value_placeholder": "Attribuutwaarde",
|
||||
"attributes_msg_attribute_limit_exceeded": "Kon {count} nieuwe attribu(u)t(en) niet aanmaken omdat dit de maximale limiet van {limit} attribuutklassen zou overschrijden. Bestaande attributen zijn succesvol bijgewerkt.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (attribuut '{key}' heeft dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Het e-mailadres bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"attributes_msg_email_or_userid_required": "E-mailadres of userId is vereist. De bestaande waarden zijn behouden.",
|
||||
"attributes_msg_new_attribute_created": "Nieuw attribuut '{key}' aangemaakt met type '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "De userId bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"contact_deleted_successfully": "Contact succesvol verwijderd",
|
||||
"contact_not_found": "Er is geen dergelijk contact gevonden",
|
||||
"contacts_table_refresh": "Vernieuw contacten",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Systeemkenmerken",
|
||||
"unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes",
|
||||
"unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribuut \"{key}\" is getypeerd als \"{dataType}\" maar CSV bevat ongeldige waarden: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Dubbele koppelingen gevonden voor de volgende attributen: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Bestandsgrootte overschrijdt de maximale limiet van 800KB",
|
||||
"upload_contacts_error_generic": "Er is een fout opgetreden bij het uploaden van de contacten. Probeer het later opnieuw.",
|
||||
"upload_contacts_error_invalid_file_type": "Upload een CSV-bestand",
|
||||
"upload_contacts_error_no_valid_contacts": "Het geüploade CSV-bestand bevat geen geldige contacten, zie het voorbeeld CSV-bestand voor het juiste formaat.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks attribuut",
|
||||
"upload_contacts_modal_attributes_description": "Wijs de kolommen in uw CSV toe aan de attributen in Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nieuw attribuut",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Nog geen attributen!",
|
||||
"no_filters_yet": "Er zijn nog geen filters!",
|
||||
"no_segments_yet": "Je hebt momenteel geen opgeslagen segmenten.",
|
||||
"operator_contains": "bevat",
|
||||
"operator_does_not_contain": "bevat niet",
|
||||
"operator_ends_with": "eindigt met",
|
||||
"operator_is_after": "is na",
|
||||
"operator_is_before": "is eerder",
|
||||
"operator_is_between": "is tussen",
|
||||
"operator_is_newer_than": "is nieuwer dan",
|
||||
"operator_is_not_set": "is niet ingesteld",
|
||||
"operator_is_older_than": "is ouder dan",
|
||||
"operator_is_same_day": "is dezelfde dag",
|
||||
"operator_is_set": "is ingesteld",
|
||||
"operator_starts_with": "begint met",
|
||||
"operator_title_contains": "Bevat",
|
||||
"operator_title_does_not_contain": "Bevat niet",
|
||||
"operator_title_ends_with": "Eindigt met",
|
||||
"operator_title_equals": "Gelijk aan",
|
||||
"operator_title_greater_equal": "Groter dan of gelijk aan",
|
||||
"operator_title_greater_than": "Groter dan",
|
||||
"operator_title_is_after": "Is na",
|
||||
"operator_title_is_before": "Is eerder",
|
||||
"operator_title_is_between": "Is tussen",
|
||||
"operator_title_is_newer_than": "Is nieuwer dan",
|
||||
"operator_title_is_not_set": "Is niet ingesteld",
|
||||
"operator_title_is_older_than": "Is ouder dan",
|
||||
"operator_title_is_same_day": "Is dezelfde dag",
|
||||
"operator_title_is_set": "Is ingesteld",
|
||||
"operator_title_less_equal": "Kleiner dan of gelijk aan",
|
||||
"operator_title_less_than": "Kleiner dan",
|
||||
"operator_title_not_equals": "Is niet gelijk aan",
|
||||
"operator_title_starts_with": "Begint met",
|
||||
"operator_title_user_is_in": "Gebruiker zit in",
|
||||
"operator_title_user_is_not_in": "Gebruiker zit niet in",
|
||||
"operator_user_is_in": "Gebruiker zit in",
|
||||
"operator_user_is_not_in": "Gebruiker zit niet in",
|
||||
"person_and_attributes": "Persoon & attributen",
|
||||
"phone": "Telefoon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Verwijder het segment uit deze enquêtes om het te kunnen verwijderen.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atributo atualizado com sucesso",
|
||||
"attribute_value": "Valor",
|
||||
"attribute_value_placeholder": "Valor do atributo",
|
||||
"attributes_msg_attribute_limit_exceeded": "Não foi possível criar {count} novo(s) atributo(s), pois excederia o limite máximo de {limit} classes de atributos. Os atributos existentes foram atualizados com sucesso.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (atributo '{key}' tem dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "O e-mail já existe para este ambiente e não foi atualizado.",
|
||||
"attributes_msg_email_or_userid_required": "E-mail ou userId é obrigatório. Os valores existentes foram preservados.",
|
||||
"attributes_msg_new_attribute_created": "Novo atributo '{key}' criado com tipo '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "O userId já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"contact_not_found": "Nenhum contato encontrado",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Atributos do sistema",
|
||||
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
|
||||
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
|
||||
"upload_contacts_error_attribute_type_mismatch": "O atributo \"{key}\" está tipado como \"{dataType}\", mas o CSV contém valores inválidos: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Mapeamentos duplicados encontrados para os seguintes atributos: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "O tamanho do arquivo excede o limite máximo de 800KB",
|
||||
"upload_contacts_error_generic": "Ocorreu um erro ao fazer upload dos contatos. Por favor, tente novamente mais tarde.",
|
||||
"upload_contacts_error_invalid_file_type": "Por favor, faça upload de um arquivo CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "O arquivo CSV enviado não contém nenhum contato válido, por favor veja o arquivo CSV de exemplo para o formato correto.",
|
||||
"upload_contacts_modal_attribute_header": "Atributo do Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mapeie as colunas do seu CSV para os atributos no Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Novo atributo",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Ainda não tem atributos!",
|
||||
"no_filters_yet": "Ainda não tem filtros!",
|
||||
"no_segments_yet": "Você não tem segmentos salvos no momento.",
|
||||
"operator_contains": "contém",
|
||||
"operator_does_not_contain": "não contém",
|
||||
"operator_ends_with": "termina com",
|
||||
"operator_is_after": "é depois",
|
||||
"operator_is_before": "é antes",
|
||||
"operator_is_between": "está entre",
|
||||
"operator_is_newer_than": "é mais recente que",
|
||||
"operator_is_not_set": "não está definido",
|
||||
"operator_is_older_than": "é mais antigo que",
|
||||
"operator_is_same_day": "é no mesmo dia",
|
||||
"operator_is_set": "está definido",
|
||||
"operator_starts_with": "começa com",
|
||||
"operator_title_contains": "Contém",
|
||||
"operator_title_does_not_contain": "Não contém",
|
||||
"operator_title_ends_with": "Termina com",
|
||||
"operator_title_equals": "Igual",
|
||||
"operator_title_greater_equal": "Maior ou igual a",
|
||||
"operator_title_greater_than": "Maior que",
|
||||
"operator_title_is_after": "É depois",
|
||||
"operator_title_is_before": "É antes",
|
||||
"operator_title_is_between": "Está entre",
|
||||
"operator_title_is_newer_than": "É mais recente que",
|
||||
"operator_title_is_not_set": "Não está definido",
|
||||
"operator_title_is_older_than": "É mais antigo que",
|
||||
"operator_title_is_same_day": "É no mesmo dia",
|
||||
"operator_title_is_set": "Está definido",
|
||||
"operator_title_less_equal": "Menor ou igual a",
|
||||
"operator_title_less_than": "Menor que",
|
||||
"operator_title_not_equals": "Diferente de",
|
||||
"operator_title_starts_with": "Começa com",
|
||||
"operator_title_user_is_in": "Usuário está em",
|
||||
"operator_title_user_is_not_in": "Usuário não está em",
|
||||
"operator_user_is_in": "Usuário está em",
|
||||
"operator_user_is_not_in": "Usuário não está em",
|
||||
"person_and_attributes": "Pessoa & Atributos",
|
||||
"phone": "Celular",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento dessas pesquisas para deletá-lo.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atributo atualizado com sucesso",
|
||||
"attribute_value": "Valor",
|
||||
"attribute_value_placeholder": "Valor do atributo",
|
||||
"attributes_msg_attribute_limit_exceeded": "Não foi possível criar {count} novo(s) atributo(s), pois excederia o limite máximo de {limit} classes de atributos. Os atributos existentes foram atualizados com sucesso.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (o atributo '{key}' tem dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "O email já existe para este ambiente e não foi atualizado.",
|
||||
"attributes_msg_email_or_userid_required": "É necessário email ou userId. Os valores existentes foram preservados.",
|
||||
"attributes_msg_new_attribute_created": "Criado novo atributo '{key}' com tipo '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "O userId já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"contact_not_found": "Nenhum contacto encontrado",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Atributos do sistema",
|
||||
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
|
||||
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
|
||||
"upload_contacts_error_attribute_type_mismatch": "O atributo \"{key}\" está definido como \"{dataType}\", mas o CSV contém valores inválidos: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Foram encontrados mapeamentos duplicados para os seguintes atributos: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "O tamanho do ficheiro excede o limite máximo de 800KB",
|
||||
"upload_contacts_error_generic": "Ocorreu um erro ao carregar os contactos. Por favor, tenta novamente mais tarde.",
|
||||
"upload_contacts_error_invalid_file_type": "Por favor, carrega um ficheiro CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "O ficheiro CSV carregado não contém contactos válidos, por favor consulta o ficheiro CSV de exemplo para o formato correto.",
|
||||
"upload_contacts_modal_attribute_header": "Atributo Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mapeie as colunas no seu CSV para os atributos no Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Novo atributo",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Ainda não há atributos!",
|
||||
"no_filters_yet": "Ainda não há filtros!",
|
||||
"no_segments_yet": "Atualmente, não tem segmentos guardados.",
|
||||
"operator_contains": "contém",
|
||||
"operator_does_not_contain": "não contém",
|
||||
"operator_ends_with": "termina com",
|
||||
"operator_is_after": "é depois",
|
||||
"operator_is_before": "é antes",
|
||||
"operator_is_between": "está entre",
|
||||
"operator_is_newer_than": "é mais recente que",
|
||||
"operator_is_not_set": "não está definido",
|
||||
"operator_is_older_than": "é mais antigo que",
|
||||
"operator_is_same_day": "é no mesmo dia",
|
||||
"operator_is_set": "está definido",
|
||||
"operator_starts_with": "começa com",
|
||||
"operator_title_contains": "Contém",
|
||||
"operator_title_does_not_contain": "Não contém",
|
||||
"operator_title_ends_with": "Termina com",
|
||||
"operator_title_equals": "Igual",
|
||||
"operator_title_greater_equal": "Maior ou igual a",
|
||||
"operator_title_greater_than": "Maior que",
|
||||
"operator_title_is_after": "É depois",
|
||||
"operator_title_is_before": "É antes",
|
||||
"operator_title_is_between": "Está entre",
|
||||
"operator_title_is_newer_than": "É mais recente que",
|
||||
"operator_title_is_not_set": "Não está definido",
|
||||
"operator_title_is_older_than": "É mais antigo que",
|
||||
"operator_title_is_same_day": "É no mesmo dia",
|
||||
"operator_title_is_set": "Está definido",
|
||||
"operator_title_less_equal": "Menor ou igual a",
|
||||
"operator_title_less_than": "Menor que",
|
||||
"operator_title_not_equals": "Diferente de",
|
||||
"operator_title_starts_with": "Começa com",
|
||||
"operator_title_user_is_in": "O utilizador está em",
|
||||
"operator_title_user_is_not_in": "O utilizador não está em",
|
||||
"operator_user_is_in": "O utilizador está em",
|
||||
"operator_user_is_not_in": "O utilizador não está em",
|
||||
"person_and_attributes": "Pessoa e Atributos",
|
||||
"phone": "Telefone",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento destes questionários para o eliminar.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atribut actualizat cu succes",
|
||||
"attribute_value": "Valoare",
|
||||
"attribute_value_placeholder": "Valoare atribut",
|
||||
"attributes_msg_attribute_limit_exceeded": "Nu s-au putut crea {count, plural, one {1 atribut nou} few {# atribute noi} other {# de atribute noi}} deoarece s-ar depăși limita maximă de {limit, plural, one {1 clasă de atribute} few {# clase de atribute} other {# de clase de atribute}}. Atributele existente au fost actualizate cu succes.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (atributul „{key}” are dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Emailul există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"attributes_msg_email_or_userid_required": "Este necesar fie un email, fie un userId. Valorile existente au fost păstrate.",
|
||||
"attributes_msg_new_attribute_created": "A fost creat atributul nou „{key}” cu tipul „{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "UserId-ul există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"contact_deleted_successfully": "Contact șters cu succes",
|
||||
"contact_not_found": "Nu a fost găsit niciun contact",
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Atribute de sistem",
|
||||
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
|
||||
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Atributul „{key}” este de tipul „{dataType}”, dar CSV-ul conține valori invalide: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Au fost găsite mapări duplicate pentru următoarele atribute: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Dimensiunea fișierului depășește limita maximă de 800KB",
|
||||
"upload_contacts_error_generic": "A apărut o eroare la încărcarea contactelor. Te rugăm să încerci din nou mai târziu.",
|
||||
"upload_contacts_error_invalid_file_type": "Te rugăm să încarci un fișier CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "Fișierul CSV încărcat nu conține contacte valide. Consultă fișierul CSV de exemplu pentru formatul corect.",
|
||||
"upload_contacts_modal_attribute_header": "Atribut Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mapează coloanele din CSV-ul tău la atributele din Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Atribut nou",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Niciun atribut încă!",
|
||||
"no_filters_yet": "Nu există filtre încă!",
|
||||
"no_segments_yet": "În prezent nu aveți segmente salvate.",
|
||||
"operator_contains": "conține",
|
||||
"operator_does_not_contain": "nu conține",
|
||||
"operator_ends_with": "se termină cu",
|
||||
"operator_is_after": "este după",
|
||||
"operator_is_before": "este înainte",
|
||||
"operator_is_between": "este între",
|
||||
"operator_is_newer_than": "este mai nou decât",
|
||||
"operator_is_not_set": "nu este setat",
|
||||
"operator_is_older_than": "este mai vechi decât",
|
||||
"operator_is_same_day": "este în aceeași zi",
|
||||
"operator_is_set": "este setat",
|
||||
"operator_starts_with": "începe cu",
|
||||
"operator_title_contains": "Conține",
|
||||
"operator_title_does_not_contain": "Nu conține",
|
||||
"operator_title_ends_with": "Se termină cu",
|
||||
"operator_title_equals": "Egal",
|
||||
"operator_title_greater_equal": "Mai mare sau egal cu",
|
||||
"operator_title_greater_than": "Mai mare decât",
|
||||
"operator_title_is_after": "Este după",
|
||||
"operator_title_is_before": "Este înainte",
|
||||
"operator_title_is_between": "Este între",
|
||||
"operator_title_is_newer_than": "Este mai nou decât",
|
||||
"operator_title_is_not_set": "Nu este setat",
|
||||
"operator_title_is_older_than": "Este mai vechi decât",
|
||||
"operator_title_is_same_day": "Este în aceeași zi",
|
||||
"operator_title_is_set": "Este setat",
|
||||
"operator_title_less_equal": "Mai mic sau egal cu",
|
||||
"operator_title_less_than": "Mai mic decât",
|
||||
"operator_title_not_equals": "Nu este egal cu",
|
||||
"operator_title_starts_with": "Începe cu",
|
||||
"operator_title_user_is_in": "Utilizatorul este în",
|
||||
"operator_title_user_is_not_in": "Utilizatorul nu este în",
|
||||
"operator_user_is_in": "Utilizatorul este în",
|
||||
"operator_user_is_not_in": "Utilizatorul nu este în",
|
||||
"person_and_attributes": "Persoană & Atribute",
|
||||
"phone": "Telefon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Vă rugăm să eliminați segmentul din aceste chestionare pentru a-l șterge.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Атрибут успешно обновлён",
|
||||
"attribute_value": "Значение",
|
||||
"attribute_value_placeholder": "Значение атрибута",
|
||||
"attributes_msg_attribute_limit_exceeded": "Не удалось создать {count} новых атрибута, так как это превысит максимальное количество классов атрибутов: {limit}. Существующие атрибуты были успешно обновлены.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (атрибут «{key}» имеет тип данных: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Этот email уже существует в данной среде и не был обновлён.",
|
||||
"attributes_msg_email_or_userid_required": "Требуется указать либо email, либо userId. Существующие значения были сохранены.",
|
||||
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
|
||||
"attributes_msg_userid_already_exists": "Этот userId уже существует в данной среде и не был обновлён.",
|
||||
"contact_deleted_successfully": "Контакт успешно удалён",
|
||||
"contact_not_found": "Такой контакт не найден",
|
||||
"contacts_table_refresh": "Обновить контакты",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Системные атрибуты",
|
||||
"unlock_contacts_description": "Управляйте контактами и отправляйте целевые опросы",
|
||||
"unlock_contacts_title": "Откройте доступ к контактам с более высоким тарифом",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Атрибут «{key}» имеет тип «{dataType}», но в CSV обнаружены некорректные значения: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Обнаружены дублирующиеся сопоставления для следующих атрибутов: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Размер файла превышает максимальный лимит 800 КБ",
|
||||
"upload_contacts_error_generic": "Произошла ошибка при загрузке контактов. Пожалуйста, попробуй ещё раз позже.",
|
||||
"upload_contacts_error_invalid_file_type": "Пожалуйста, загрузи файл в формате CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "Загруженный CSV-файл не содержит ни одного корректного контакта. Ознакомься с примером CSV-файла для правильного формата.",
|
||||
"upload_contacts_modal_attribute_header": "Атрибут Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Сопоставьте столбцы в вашем CSV с атрибутами в Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Новый атрибут",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Пока нет атрибутов!",
|
||||
"no_filters_yet": "Пока нет фильтров!",
|
||||
"no_segments_yet": "У вас пока нет сохранённых сегментов.",
|
||||
"operator_contains": "содержит",
|
||||
"operator_does_not_contain": "не содержит",
|
||||
"operator_ends_with": "оканчивается на",
|
||||
"operator_is_after": "после",
|
||||
"operator_is_before": "до",
|
||||
"operator_is_between": "находится между",
|
||||
"operator_is_newer_than": "новее чем",
|
||||
"operator_is_not_set": "не задано",
|
||||
"operator_is_older_than": "старше чем",
|
||||
"operator_is_same_day": "в тот же день",
|
||||
"operator_is_set": "задано",
|
||||
"operator_starts_with": "начинается с",
|
||||
"operator_title_contains": "Содержит",
|
||||
"operator_title_does_not_contain": "Не содержит",
|
||||
"operator_title_ends_with": "Оканчивается на",
|
||||
"operator_title_equals": "Равно",
|
||||
"operator_title_greater_equal": "Больше или равно",
|
||||
"operator_title_greater_than": "Больше чем",
|
||||
"operator_title_is_after": "После",
|
||||
"operator_title_is_before": "До",
|
||||
"operator_title_is_between": "Находится между",
|
||||
"operator_title_is_newer_than": "Новее чем",
|
||||
"operator_title_is_not_set": "Не задано",
|
||||
"operator_title_is_older_than": "Старше чем",
|
||||
"operator_title_is_same_day": "В тот же день",
|
||||
"operator_title_is_set": "Задано",
|
||||
"operator_title_less_equal": "Меньше или равно",
|
||||
"operator_title_less_than": "Меньше чем",
|
||||
"operator_title_not_equals": "Не равно",
|
||||
"operator_title_starts_with": "Начинается с",
|
||||
"operator_title_user_is_in": "Пользователь входит в",
|
||||
"operator_title_user_is_not_in": "Пользователь не входит в",
|
||||
"operator_user_is_in": "Пользователь входит в",
|
||||
"operator_user_is_not_in": "Пользователь не входит в",
|
||||
"person_and_attributes": "Пользователь и атрибуты",
|
||||
"phone": "Телефон",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Пожалуйста, удалите этот сегмент из указанных опросов, чтобы его удалить.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attributet har uppdaterats",
|
||||
"attribute_value": "Värde",
|
||||
"attribute_value_placeholder": "Attributvärde",
|
||||
"attributes_msg_attribute_limit_exceeded": "Kunde inte skapa {count} nya attribut eftersom det skulle överskrida maxgränsen på {limit} attributklasser. Befintliga attribut uppdaterades utan problem.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (attributet '{key}' har dataTyp: {dataType})",
|
||||
"attributes_msg_email_already_exists": "E-postadressen finns redan för den här miljön och uppdaterades inte.",
|
||||
"attributes_msg_email_or_userid_required": "Antingen e-post eller userId krävs. De befintliga värdena behölls.",
|
||||
"attributes_msg_new_attribute_created": "Nytt attribut '{key}' med typen '{dataType}' har skapats",
|
||||
"attributes_msg_userid_already_exists": "UserId finns redan för den här miljön och uppdaterades inte.",
|
||||
"contact_deleted_successfully": "Kontakt borttagen",
|
||||
"contact_not_found": "Ingen sådan kontakt hittades",
|
||||
"contacts_table_refresh": "Uppdatera kontakter",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "Systemattribut",
|
||||
"unlock_contacts_description": "Hantera kontakter och skicka ut riktade enkäter",
|
||||
"unlock_contacts_title": "Lås upp kontakter med en högre plan",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attributet \"{key}\" är av typen \"{dataType}\" men CSV-filen innehåller ogiltiga värden: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Dubblettmappningar hittades för följande attribut: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Filstorleken överskrider maxgränsen på 800 KB",
|
||||
"upload_contacts_error_generic": "Ett fel uppstod vid uppladdning av kontakter. Försök igen senare.",
|
||||
"upload_contacts_error_invalid_file_type": "Ladda upp en CSV-fil",
|
||||
"upload_contacts_error_no_valid_contacts": "Den uppladdade CSV-filen innehåller inga giltiga kontakter, se exempel på CSV-fil för korrekt format.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks-attribut",
|
||||
"upload_contacts_modal_attributes_description": "Mappa kolumnerna i din CSV till attributen i Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nytt attribut",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "Inga attribut ännu!",
|
||||
"no_filters_yet": "Det finns inga filter ännu!",
|
||||
"no_segments_yet": "Du har för närvarande inga sparade segment.",
|
||||
"operator_contains": "innehåller",
|
||||
"operator_does_not_contain": "innehåller inte",
|
||||
"operator_ends_with": "slutar med",
|
||||
"operator_is_after": "är efter",
|
||||
"operator_is_before": "är före",
|
||||
"operator_is_between": "är mellan",
|
||||
"operator_is_newer_than": "är nyare än",
|
||||
"operator_is_not_set": "är inte satt",
|
||||
"operator_is_older_than": "är äldre än",
|
||||
"operator_is_same_day": "är samma dag",
|
||||
"operator_is_set": "är satt",
|
||||
"operator_starts_with": "börjar med",
|
||||
"operator_title_contains": "Innehåller",
|
||||
"operator_title_does_not_contain": "Innehåller inte",
|
||||
"operator_title_ends_with": "Slutar med",
|
||||
"operator_title_equals": "Är lika med",
|
||||
"operator_title_greater_equal": "Större än eller lika med",
|
||||
"operator_title_greater_than": "Större än",
|
||||
"operator_title_is_after": "Är efter",
|
||||
"operator_title_is_before": "Är före",
|
||||
"operator_title_is_between": "Är mellan",
|
||||
"operator_title_is_newer_than": "Är nyare än",
|
||||
"operator_title_is_not_set": "Är inte satt",
|
||||
"operator_title_is_older_than": "Är äldre än",
|
||||
"operator_title_is_same_day": "Är samma dag",
|
||||
"operator_title_is_set": "Är satt",
|
||||
"operator_title_less_equal": "Mindre än eller lika med",
|
||||
"operator_title_less_than": "Mindre än",
|
||||
"operator_title_not_equals": "Är inte lika med",
|
||||
"operator_title_starts_with": "Börjar med",
|
||||
"operator_title_user_is_in": "Användaren är i",
|
||||
"operator_title_user_is_not_in": "Användaren är inte i",
|
||||
"operator_user_is_in": "Användaren är i",
|
||||
"operator_user_is_not_in": "Användaren är inte i",
|
||||
"person_and_attributes": "Person och attribut",
|
||||
"phone": "Telefon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Vänligen ta bort segmentet från dessa enkäter för att kunna ta bort det.",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "属性更新成功",
|
||||
"attribute_value": "值",
|
||||
"attribute_value_placeholder": "属性值",
|
||||
"attributes_msg_attribute_limit_exceeded": "无法创建 {count} 个新属性,因为这将超过最多 {limit} 个属性类别的限制。已有属性已成功更新。",
|
||||
"attributes_msg_attribute_type_validation_error": "{error}(属性“{key}”的数据类型为:{dataType})",
|
||||
"attributes_msg_email_already_exists": "该邮箱已存在于当前环境,未进行更新。",
|
||||
"attributes_msg_email_or_userid_required": "必须填写邮箱或 userId。已保留原有值。",
|
||||
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "该 userId 已存在于当前环境,未进行更新。",
|
||||
"contact_deleted_successfully": "联系人 删除 成功",
|
||||
"contact_not_found": "未找到此 联系人",
|
||||
"contacts_table_refresh": "刷新 联系人",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "系统属性",
|
||||
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
|
||||
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
|
||||
"upload_contacts_error_attribute_type_mismatch": "属性“{key}”的数据类型为“{dataType}”,但 CSV 文件中包含无效值:{values}",
|
||||
"upload_contacts_error_duplicate_mappings": "以下属性存在重复映射:{attributes}",
|
||||
"upload_contacts_error_file_too_large": "文件大小超过最大限制 800KB",
|
||||
"upload_contacts_error_generic": "上传联系人时发生错误,请稍后再试。",
|
||||
"upload_contacts_error_invalid_file_type": "请上传 CSV 文件",
|
||||
"upload_contacts_error_no_valid_contacts": "上传的 CSV 文件中不包含任何有效联系人,请参考示例 CSV 文件获取正确格式。",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks 属性",
|
||||
"upload_contacts_modal_attributes_description": "将您 CSV 中的列映射到 Formbricks 中的属性。",
|
||||
"upload_contacts_modal_attributes_new": "新 属性",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "暂无属性!",
|
||||
"no_filters_yet": "还 没有 筛选器!",
|
||||
"no_segments_yet": "您 目前 尚无 保存 的 段。",
|
||||
"operator_contains": "包含",
|
||||
"operator_does_not_contain": "不包含",
|
||||
"operator_ends_with": "以...结束",
|
||||
"operator_is_after": "在...之后",
|
||||
"operator_is_before": "在...之前",
|
||||
"operator_is_between": "介于...之间",
|
||||
"operator_is_newer_than": "比...更新",
|
||||
"operator_is_not_set": "未设置",
|
||||
"operator_is_older_than": "比...更早",
|
||||
"operator_is_same_day": "同一天",
|
||||
"operator_is_set": "已设置",
|
||||
"operator_starts_with": "以...开始",
|
||||
"operator_title_contains": "包含",
|
||||
"operator_title_does_not_contain": "不包含",
|
||||
"operator_title_ends_with": "以...结束",
|
||||
"operator_title_equals": "等于",
|
||||
"operator_title_greater_equal": "大于或等于",
|
||||
"operator_title_greater_than": "大于",
|
||||
"operator_title_is_after": "在...之后",
|
||||
"operator_title_is_before": "在...之前",
|
||||
"operator_title_is_between": "介于...之间",
|
||||
"operator_title_is_newer_than": "比...更新",
|
||||
"operator_title_is_not_set": "未设置",
|
||||
"operator_title_is_older_than": "比...更早",
|
||||
"operator_title_is_same_day": "同一天",
|
||||
"operator_title_is_set": "已设置",
|
||||
"operator_title_less_equal": "小于或等于",
|
||||
"operator_title_less_than": "小于",
|
||||
"operator_title_not_equals": "不等于",
|
||||
"operator_title_starts_with": "以...开始",
|
||||
"operator_title_user_is_in": "用户属于",
|
||||
"operator_title_user_is_not_in": "用户不属于",
|
||||
"operator_user_is_in": "用户属于",
|
||||
"operator_user_is_not_in": "用户不属于",
|
||||
"person_and_attributes": "人员 及 属性",
|
||||
"phone": "电话",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "请 从 这些 调查 中 移除 该 部分 以 进行 删除。",
|
||||
|
||||
@@ -634,6 +634,12 @@
|
||||
"attribute_updated_successfully": "屬性更新成功",
|
||||
"attribute_value": "值",
|
||||
"attribute_value_placeholder": "屬性值",
|
||||
"attributes_msg_attribute_limit_exceeded": "無法建立 {count} 個新屬性,因為這樣會超過 {limit} 個屬性類別的上限。現有屬性已成功更新。",
|
||||
"attributes_msg_attribute_type_validation_error": "{error}(屬性「{key}」的資料型別為:{dataType})",
|
||||
"attributes_msg_email_already_exists": "此環境已存在該 email,未進行更新。",
|
||||
"attributes_msg_email_or_userid_required": "必須提供 email 或 userId。已保留現有值。",
|
||||
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
|
||||
"attributes_msg_userid_already_exists": "此環境已存在該 userId,未進行更新。",
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"contact_not_found": "找不到此聯絡人",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
@@ -678,6 +684,12 @@
|
||||
"system_attributes": "系統屬性",
|
||||
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
|
||||
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
|
||||
"upload_contacts_error_attribute_type_mismatch": "屬性「{key}」的類型為「{dataType}」,但 CSV 檔案中包含無效的值:{values}",
|
||||
"upload_contacts_error_duplicate_mappings": "以下屬性有重複對應:{attributes}",
|
||||
"upload_contacts_error_file_too_large": "檔案大小超過 800KB 的上限",
|
||||
"upload_contacts_error_generic": "上傳聯絡人時發生錯誤,請稍後再試。",
|
||||
"upload_contacts_error_invalid_file_type": "請上傳 CSV 檔案",
|
||||
"upload_contacts_error_no_valid_contacts": "上傳的 CSV 檔案中沒有任何有效的聯絡人,請參考範例 CSV 檔案以取得正確格式。",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks 屬性",
|
||||
"upload_contacts_modal_attributes_description": "將 CSV 中的欄位對應到 Formbricks 中的屬性。",
|
||||
"upload_contacts_modal_attributes_new": "新增屬性",
|
||||
@@ -864,6 +876,40 @@
|
||||
"no_attributes_yet": "尚無屬性!",
|
||||
"no_filters_yet": "尚無篩選器!",
|
||||
"no_segments_yet": "您目前沒有已儲存的區隔。",
|
||||
"operator_contains": "包含",
|
||||
"operator_does_not_contain": "不包含",
|
||||
"operator_ends_with": "結尾為",
|
||||
"operator_is_after": "在之後",
|
||||
"operator_is_before": "在之前",
|
||||
"operator_is_between": "介於",
|
||||
"operator_is_newer_than": "較新於",
|
||||
"operator_is_not_set": "未設定",
|
||||
"operator_is_older_than": "較舊於",
|
||||
"operator_is_same_day": "同一天",
|
||||
"operator_is_set": "已設定",
|
||||
"operator_starts_with": "開頭為",
|
||||
"operator_title_contains": "包含",
|
||||
"operator_title_does_not_contain": "不包含",
|
||||
"operator_title_ends_with": "結尾為",
|
||||
"operator_title_equals": "等於",
|
||||
"operator_title_greater_equal": "大於或等於",
|
||||
"operator_title_greater_than": "大於",
|
||||
"operator_title_is_after": "在之後",
|
||||
"operator_title_is_before": "在之前",
|
||||
"operator_title_is_between": "介於",
|
||||
"operator_title_is_newer_than": "較新於",
|
||||
"operator_title_is_not_set": "未設定",
|
||||
"operator_title_is_older_than": "較舊於",
|
||||
"operator_title_is_same_day": "同一天",
|
||||
"operator_title_is_set": "已設定",
|
||||
"operator_title_less_equal": "小於或等於",
|
||||
"operator_title_less_than": "小於",
|
||||
"operator_title_not_equals": "不等於",
|
||||
"operator_title_starts_with": "開頭為",
|
||||
"operator_title_user_is_in": "使用者屬於",
|
||||
"operator_title_user_is_not_in": "使用者不屬於",
|
||||
"operator_user_is_in": "使用者屬於",
|
||||
"operator_user_is_not_in": "使用者不屬於",
|
||||
"person_and_attributes": "人員與屬性",
|
||||
"phone": "電話",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "請從這些問卷中移除區隔,以便將其刪除。",
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
|
||||
const ZGeneratePersonalSurveyLinkAction = z.object({
|
||||
contactId: ZId,
|
||||
@@ -63,105 +58,3 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
|
||||
surveyUrl: result.data,
|
||||
};
|
||||
});
|
||||
|
||||
const ZUpdateContactAttributesAction = z.object({
|
||||
contactId: ZId,
|
||||
attributes: ZContactAttributes,
|
||||
});
|
||||
|
||||
export const updateContactAttributesAction = authenticatedActionClient
|
||||
.schema(ZUpdateContactAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Get userId from contact attributes
|
||||
const userIdAttribute = await prisma.contactAttribute.findFirst({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: "userId" },
|
||||
},
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
if (!userIdAttribute) {
|
||||
throw new InvalidInputError("Contact does not have a userId attribute");
|
||||
}
|
||||
|
||||
const result = await updateAttributes(
|
||||
parsedInput.contactId,
|
||||
userIdAttribute.value,
|
||||
contact.environmentId,
|
||||
parsedInput.attributes
|
||||
);
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZDeleteContactAttributeAction = z.object({
|
||||
contactId: ZId,
|
||||
attributeKey: z.string(),
|
||||
});
|
||||
|
||||
export const deleteContactAttributeAction = authenticatedActionClient
|
||||
.schema(ZDeleteContactAttributeAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Delete the attribute
|
||||
await prisma.contactAttribute.deleteMany({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: parsedInput.attributeKey },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributesWithMetadata } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { getContactAttributeDataTypeIcon } from "@/modules/ee/contacts/utils";
|
||||
@@ -8,9 +8,9 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
const t = await getTranslate();
|
||||
const [contact, attributesWithMetadata] = await Promise.all([
|
||||
const [contact, attributesWithKeyInfo] = await Promise.all([
|
||||
getContact(contactId),
|
||||
getContactAttributesWithMetadata(contactId),
|
||||
getContactAttributesWithKeyInfo(contactId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
@@ -20,15 +20,15 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
const responses = await getResponsesByContactId(contactId);
|
||||
const numberOfResponses = responses?.length || 0;
|
||||
|
||||
const systemAttributes = attributesWithMetadata
|
||||
const systemAttributes = attributesWithKeyInfo
|
||||
.filter((attr) => attr.type === "default")
|
||||
.sort((a, b) => (a.name || a.key).localeCompare(b.name || b.key));
|
||||
|
||||
const customAttributes = attributesWithMetadata
|
||||
const customAttributes = attributesWithKeyInfo
|
||||
.filter((attr) => attr.type === "custom")
|
||||
.sort((a, b) => (a.name || a.key).localeCompare(b.name || b.key));
|
||||
|
||||
const renderAttributeValue = (attr: (typeof attributesWithMetadata)[number]) => {
|
||||
const renderAttributeValue = (attr: (typeof attributesWithKeyInfo)[number]) => {
|
||||
if (!attr.value) {
|
||||
return <span className="text-slate-300">{t("environments.contacts.not_provided")}</span>;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
interface TContactAttributeWithKeyInfo {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
@@ -28,7 +28,7 @@ interface ContactControlBarProps {
|
||||
isQuotasAllowed: boolean;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
currentAttributes: AttributeWithMetadata[];
|
||||
currentAttributes: TContactAttributeWithKeyInfo[];
|
||||
}
|
||||
|
||||
export const ContactControlBar = ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributesWithMetadata } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -21,12 +21,12 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, publishedLinkSurveys, attributesWithMetadata, allAttributeKeys] =
|
||||
const [environmentTags, contact, publishedLinkSurveys, attributesWithKeyInfo, allAttributeKeys] =
|
||||
await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributesWithMetadata(params.contactId),
|
||||
getContactAttributesWithKeyInfo(params.contactId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
// Derive contact identifier from metadata array
|
||||
const getAttributeValue = (key: string): string | undefined => {
|
||||
return attributesWithMetadata.find((attr) => attr.key === key)?.value;
|
||||
return attributesWithKeyInfo.find((attr) => attr.key === key)?.value;
|
||||
};
|
||||
|
||||
const contactIdentifier = getAttributeValue("email") || getAttributeValue("userId") || "";
|
||||
@@ -51,7 +51,7 @@ export const SingleContactPage = async (props: {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
currentAttributes={attributesWithMetadata}
|
||||
currentAttributes={attributesWithKeyInfo}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -124,8 +124,13 @@ export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCre
|
||||
parsedInput.duplicateContactsAction,
|
||||
parsedInput.attributeMap
|
||||
);
|
||||
|
||||
if ("validationErrors" in result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
contacts: result,
|
||||
contacts: result.contacts,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ZJsContactsUpdateAttributeInput } from "@formbricks/types/js";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { formatAttributeMessage, updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getContactByUserIdWithAttributes } from "./lib/contact";
|
||||
|
||||
@@ -119,8 +119,8 @@ export const PUT = withV1ApiWrapper({
|
||||
{
|
||||
changed: true,
|
||||
message: "The person was successfully updated.",
|
||||
...(messages && messages.length > 0 ? { messages } : {}),
|
||||
...(errors && errors.length > 0 ? { errors } : {}),
|
||||
...(messages && messages.length > 0 ? { messages: messages.map(formatAttributeMessage) } : {}),
|
||||
...(errors && errors.length > 0 ? { errors: errors.map(formatAttributeMessage) } : {}),
|
||||
},
|
||||
true
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TBaseFilter, TBaseFilters } from "@formbricks/types/segment";
|
||||
import { TBaseFilters } from "@formbricks/types/segment";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
@@ -96,20 +96,16 @@ export const getPersonSegmentIds = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
const personSegments: { id: string; filters: TBaseFilter[] }[] = [];
|
||||
|
||||
// Use Prisma-based evaluation for all segments
|
||||
// Device filters are evaluated at query build time using the provided deviceType
|
||||
for (const segment of segments) {
|
||||
const filters = segment.filters as TBaseFilters;
|
||||
const segmentPromises = segments.map(async (segment) => {
|
||||
const filters = segment.filters;
|
||||
const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType);
|
||||
return isIncluded ? segment.id : null;
|
||||
});
|
||||
|
||||
if (isIncluded) {
|
||||
personSegments.push(segment);
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(segmentPromises);
|
||||
|
||||
return personSegments.map((segment) => segment.id);
|
||||
return results.filter((id): id is string => id !== null);
|
||||
} catch (error) {
|
||||
// Log error for debugging but don't throw to prevent "segments is not iterable" error
|
||||
logger.warn(
|
||||
|
||||
@@ -152,7 +152,9 @@ describe("updateUser", () => {
|
||||
test("should return messages from updateAttributes if any", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData as any);
|
||||
const newAttributes = { company: "Formbricks" };
|
||||
const updateMessages = ["Attribute 'company' created."];
|
||||
const updateMessages = [
|
||||
{ code: "new_attribute_created", params: { key: "company", dataType: "string" } },
|
||||
];
|
||||
vi.mocked(updateAttributes).mockResolvedValue({ success: true, messages: updateMessages });
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
@@ -163,7 +165,7 @@ describe("updateUser", () => {
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
expect(result.messages).toEqual(updateMessages);
|
||||
expect(result.messages).toEqual(["Created new attribute 'company' with type 'string'"]);
|
||||
});
|
||||
|
||||
test("should use device type 'phone'", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { formatAttributeMessage, updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
|
||||
/**
|
||||
@@ -193,8 +193,8 @@ export const updateUser = async (
|
||||
errors: updateAttrErrors,
|
||||
} = await updateAttributes(contactData.id, userId, environmentId, attributes);
|
||||
|
||||
messages = updateAttrMessages ?? [];
|
||||
errors = updateAttrErrors ?? [];
|
||||
messages = updateAttrMessages?.map(formatAttributeMessage) ?? [];
|
||||
errors = updateAttrErrors?.map(formatAttributeMessage) ?? [];
|
||||
|
||||
// Update language if provided (used in response state)
|
||||
if (success && attributes.language) {
|
||||
|
||||
@@ -108,18 +108,24 @@ const determineAttributeTypes = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates attribute values against their expected types and downgrades to string if invalid
|
||||
* Validates attribute values against their expected types.
|
||||
* For NEW keys (not yet in DB): downgrades to string if values are mixed/invalid.
|
||||
* For EXISTING keys: returns errors for invalid values (the type is already set in the DB and must be respected).
|
||||
*/
|
||||
const validateAndAdjustAttributeTypes = (
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>,
|
||||
attributeValuesByKey: Map<string, string[]>
|
||||
): void => {
|
||||
const typeValidationErrors: string[] = [];
|
||||
attributeValuesByKey: Map<string, string[]>,
|
||||
existingAttributeKeys: { key: string; dataType: TContactAttributeDataType }[]
|
||||
): { existingKeyErrors: string[] } => {
|
||||
const existingKeySet = new Set(existingAttributeKeys.map((ak) => ak.key));
|
||||
const newKeyWarnings: string[] = [];
|
||||
const existingKeyErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
if (dataType === "string") continue;
|
||||
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
const invalidValues: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
@@ -128,18 +134,34 @@ const validateAndAdjustAttributeTypes = (
|
||||
(dataType === "date" && columns.valueDate === null);
|
||||
|
||||
if (parseFailed) {
|
||||
attributeTypeMap.set(key, "string");
|
||||
typeValidationErrors.push(
|
||||
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
break;
|
||||
invalidValues.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidValues.length === 0) continue;
|
||||
|
||||
if (existingKeySet.has(key)) {
|
||||
// Existing key with a set type - invalid values must be rejected
|
||||
const sampleInvalid = invalidValues.slice(0, 3).join(", ");
|
||||
const additionalCount = invalidValues.length - 3;
|
||||
const suffix = additionalCount > 0 ? ` (and ${additionalCount.toString()} more)` : "";
|
||||
existingKeyErrors.push(
|
||||
`Attribute "${key}" is typed as "${dataType}" but received invalid values: ${sampleInvalid}${suffix}`
|
||||
);
|
||||
} else {
|
||||
// New key - safe to downgrade to string
|
||||
attributeTypeMap.set(key, "string");
|
||||
newKeyWarnings.push(
|
||||
`New attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeValidationErrors.length > 0) {
|
||||
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during bulk upload");
|
||||
if (newKeyWarnings.length > 0) {
|
||||
logger.warn({ errors: newKeyWarnings }, "Type validation warnings during bulk upload");
|
||||
}
|
||||
|
||||
return { existingKeyErrors };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -517,7 +539,21 @@ export const upsertBulkContacts = async (
|
||||
// Type Detection Phase
|
||||
const attributeValuesByKey = buildAttributeValuesByKey(contacts);
|
||||
const attributeTypeMap = determineAttributeTypes(attributeValuesByKey, existingAttributeKeys);
|
||||
validateAndAdjustAttributeTypes(attributeTypeMap, attributeValuesByKey);
|
||||
const { existingKeyErrors } = validateAndAdjustAttributeTypes(
|
||||
attributeTypeMap,
|
||||
attributeValuesByKey,
|
||||
existingAttributeKeys
|
||||
);
|
||||
|
||||
if (existingKeyErrors.length > 0) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: existingKeyErrors.map((issue) => ({
|
||||
field: "attributes",
|
||||
issue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Build contact lookup map
|
||||
const contactMap = buildExistingContactMap(existingContactsByEmail);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { updateContactAttributesAction } from "../actions";
|
||||
import { TEditContactAttributesForm, createEditContactAttributesSchema } from "../types/contact";
|
||||
import { AttributeFieldRow } from "./attribute-field-row";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
interface TContactAttributeWithKeyInfo {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
@@ -35,7 +35,7 @@ interface EditContactAttributesModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactId: string;
|
||||
currentAttributes: AttributeWithMetadata[];
|
||||
currentAttributes: TContactAttributeWithKeyInfo[];
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -177,10 +177,31 @@ export const EditContactAttributesModal = ({
|
||||
toast.success(t("environments.contacts.edit_attributes_success"));
|
||||
|
||||
if (result.data.messages && result.data.messages.length > 0) {
|
||||
result.data.messages.forEach((message) => {
|
||||
toast.error(message, { duration: 5000 });
|
||||
const translateMessage = (code: string, params: Record<string, string>): string => {
|
||||
switch (code) {
|
||||
case "email_or_userid_required":
|
||||
return t("environments.contacts.attributes_msg_email_or_userid_required");
|
||||
case "attribute_type_validation_error":
|
||||
return t("environments.contacts.attributes_msg_attribute_type_validation_error", params);
|
||||
case "email_already_exists":
|
||||
return t("environments.contacts.attributes_msg_email_already_exists");
|
||||
case "userid_already_exists":
|
||||
return t("environments.contacts.attributes_msg_userid_already_exists");
|
||||
case "attribute_limit_exceeded":
|
||||
return t("environments.contacts.attributes_msg_attribute_limit_exceeded", params);
|
||||
case "new_attribute_created":
|
||||
return t("environments.contacts.attributes_msg_new_attribute_created", params);
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
result.data.messages.forEach((msg) => {
|
||||
const errorMessage = translateMessage(msg.code, msg.params);
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
|
||||
setOpen(false);
|
||||
|
||||
@@ -54,19 +54,19 @@ export const UploadContactsCSVButton = ({
|
||||
|
||||
// Check file type
|
||||
if (!file.type && !file.name.endsWith(".csv")) {
|
||||
setError("Please upload a CSV file");
|
||||
setError(t("environments.contacts.upload_contacts_error_invalid_file_type"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
|
||||
setError("Please upload a CSV file");
|
||||
setError(t("environments.contacts.upload_contacts_error_invalid_file_type"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Max file size check (800KB)
|
||||
const maxSizeInBytes = 800 * 1024;
|
||||
if (file.size > maxSizeInBytes) {
|
||||
setError("File size exceeds the maximum limit of 800KB");
|
||||
setError(t("environments.contacts.upload_contacts_error_file_too_large"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,9 +89,7 @@ export const UploadContactsCSVButton = ({
|
||||
}
|
||||
|
||||
if (!parsedRecords.data.length) {
|
||||
setError(
|
||||
"The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format."
|
||||
);
|
||||
setError(t("environments.contacts.upload_contacts_error_no_valid_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,7 +168,11 @@ export const UploadContactsCSVButton = ({
|
||||
.filter(([_, value]) => duplicateValues.includes(value))
|
||||
.map(([key, _]) => key);
|
||||
|
||||
setError(`Duplicate mappings found for the following attributes: ${duplicateAttributeKeys.join(", ")}`);
|
||||
setError(
|
||||
t("environments.contacts.upload_contacts_error_duplicate_mappings", {
|
||||
attributes: duplicateAttributeKeys.join(", "),
|
||||
})
|
||||
);
|
||||
errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -205,6 +207,23 @@ export const UploadContactsCSVButton = ({
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
if ("validationErrors" in result.data) {
|
||||
const { validationErrors } = result.data;
|
||||
const errorMessages = validationErrors.map((err) => {
|
||||
const sampleInvalid = err.invalidValues.slice(0, 3).join(", ");
|
||||
const additionalCount = err.invalidValues.length - 3;
|
||||
const suffix = additionalCount > 0 ? ` (${additionalCount.toString()} more)` : "";
|
||||
return t("environments.contacts.upload_contacts_error_attribute_type_mismatch", {
|
||||
key: err.key,
|
||||
dataType: err.dataType,
|
||||
values: `${sampleInvalid}${suffix}`,
|
||||
});
|
||||
});
|
||||
setError(errorMessages.join("\n"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
toast.success(t("environments.contacts.upload_contacts_success"));
|
||||
resetState(true);
|
||||
@@ -227,7 +246,7 @@ export const UploadContactsCSVButton = ({
|
||||
if (csvDataErrors) {
|
||||
setError(csvDataErrors);
|
||||
} else {
|
||||
setError("An error occurred while uploading the contacts. Please try again later.");
|
||||
setError(t("environments.contacts.upload_contacts_error_generic"));
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("updateAttributes", () => {
|
||||
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).toContainEqual({ code: "email_already_exists", params: {} });
|
||||
});
|
||||
|
||||
test("skips updating userId if it already exists", async () => {
|
||||
@@ -140,7 +140,7 @@ describe("updateAttributes", () => {
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContainEqual({ code: "userid_already_exists", params: {} });
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
|
||||
@@ -174,8 +174,8 @@ describe("updateAttributes", () => {
|
||||
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.messages).toContainEqual({ code: "email_already_exists", params: {} });
|
||||
expect(result.messages).toContainEqual({ code: "userid_already_exists", params: {} });
|
||||
expect(result.ignoreEmailAttribute).toBe(true);
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
@@ -208,7 +208,12 @@ describe("updateAttributes", () => {
|
||||
const attributes = { name: "John", email: "john@example.com", new_attr: "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/);
|
||||
expect(result.messages?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "attribute_limit_exceeded",
|
||||
params: expect.objectContaining({ count: "1" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns success with only email attribute", async () => {
|
||||
@@ -430,8 +435,6 @@ describe("updateAttributes", () => {
|
||||
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."
|
||||
);
|
||||
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,47 @@ import {
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { validateAndParseAttributeValue } from "@/modules/ee/contacts/lib/validate-attribute-type";
|
||||
import {
|
||||
formatValidationError,
|
||||
validateAndParseAttributeValue,
|
||||
} from "@/modules/ee/contacts/lib/validate-attribute-type";
|
||||
|
||||
/**
|
||||
* Structured message with code and params for i18n support.
|
||||
* Used for both UI-facing messages (translated) and API/SDK responses (formatted to English).
|
||||
*/
|
||||
export interface TAttributeUpdateMessage {
|
||||
code: string;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* English templates for formatting structured messages to human-readable strings.
|
||||
* Used by SDK/API paths that return English responses.
|
||||
*/
|
||||
const MESSAGE_TEMPLATES: Record<string, string> = {
|
||||
email_or_userid_required: "Either email or userId is required. The existing values were preserved.",
|
||||
attribute_type_validation_error: "{error} (attribute '{key}' has dataType: {dataType})",
|
||||
email_already_exists: "The email already exists for this environment and was not updated.",
|
||||
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.",
|
||||
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}'",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a structured message to a human-readable English string.
|
||||
* Used for API/SDK responses.
|
||||
*/
|
||||
export const formatAttributeMessage = (msg: TAttributeUpdateMessage): string => {
|
||||
let template = MESSAGE_TEMPLATES[msg.code] || msg.code;
|
||||
for (const [key, value] of Object.entries(msg.params)) {
|
||||
template = template.replaceAll(`{${key}}`, value);
|
||||
}
|
||||
return template;
|
||||
};
|
||||
|
||||
// Default/system attributes that should not be deleted even if missing from payload
|
||||
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
|
||||
@@ -73,8 +113,8 @@ export const updateAttributes = async (
|
||||
deleteRemovedAttributes: boolean = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
messages?: string[];
|
||||
errors?: string[];
|
||||
messages?: TAttributeUpdateMessage[];
|
||||
errors?: TAttributeUpdateMessage[];
|
||||
ignoreEmailAttribute?: boolean;
|
||||
ignoreUserIdAttribute?: boolean;
|
||||
}> => {
|
||||
@@ -87,8 +127,8 @@ export const updateAttributes = async (
|
||||
|
||||
let ignoreEmailAttribute = false;
|
||||
let ignoreUserIdAttribute = false;
|
||||
const messages: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const messages: TAttributeUpdateMessage[] = [];
|
||||
const errors: TAttributeUpdateMessage[] = [];
|
||||
|
||||
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
|
||||
const emailValue =
|
||||
@@ -155,7 +195,7 @@ export const updateAttributes = async (
|
||||
if (currentUserId) {
|
||||
contactAttributes.userId = currentUserId;
|
||||
}
|
||||
messages.push("Either email or userId is required. The existing values were preserved.");
|
||||
messages.push({ code: "email_or_userid_required", params: {} });
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
@@ -187,7 +227,6 @@ export const updateAttributes = async (
|
||||
columns: { value: string; valueNumber: number | null; valueDate: Date | null };
|
||||
}[] = [];
|
||||
const newAttributes: { key: string; value: string | number }[] = [];
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(contactAttributes)) {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
@@ -203,10 +242,15 @@ export const updateAttributes = async (
|
||||
columns: validationResult.parsedValue,
|
||||
});
|
||||
} else {
|
||||
// Type mismatch - add to errors and include what type is expected
|
||||
typeValidationErrors.push(
|
||||
`${validationResult.error} (attribute '${key}' has dataType: ${attributeKey.dataType})`
|
||||
);
|
||||
// Type mismatch - add structured error
|
||||
messages.push({
|
||||
code: "attribute_type_validation_error",
|
||||
params: {
|
||||
key,
|
||||
dataType: attributeKey.dataType,
|
||||
error: formatValidationError(validationResult.error),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// New attribute - will detect type on creation
|
||||
@@ -214,17 +258,12 @@ export const updateAttributes = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Add type validation errors to messages
|
||||
if (typeValidationErrors.length > 0) {
|
||||
messages.push(...typeValidationErrors);
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
messages.push("The email already exists for this environment and was not updated.");
|
||||
messages.push({ code: "email_already_exists", params: {} });
|
||||
}
|
||||
|
||||
if (userIdExists) {
|
||||
messages.push("The userId already exists for this environment and was not updated.");
|
||||
messages.push({ code: "userid_already_exists", params: {} });
|
||||
}
|
||||
|
||||
// Update all existing attributes with typed column values
|
||||
@@ -271,9 +310,10 @@ export const updateAttributes = async (
|
||||
|
||||
// Add error message for invalid keys
|
||||
if (invalidKeys.length > 0) {
|
||||
errors.push(
|
||||
`Skipped creating attribute(s) with invalid key(s): ${invalidKeys.join(", ")}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.`
|
||||
);
|
||||
errors.push({
|
||||
code: "invalid_attribute_keys",
|
||||
params: { keys: invalidKeys.join(", ") },
|
||||
});
|
||||
logger.warn(
|
||||
{ environmentId, invalidKeys },
|
||||
"SDK tried to create attributes with invalid keys - skipping"
|
||||
@@ -285,9 +325,13 @@ export const updateAttributes = async (
|
||||
|
||||
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
|
||||
// Add warning to details about skipped attributes
|
||||
messages.push(
|
||||
`Could not create ${validNewAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`
|
||||
);
|
||||
messages.push({
|
||||
code: "attribute_limit_exceeded",
|
||||
params: {
|
||||
count: validNewAttributes.length.toString(),
|
||||
limit: MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT.toString(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Prepare new attributes with SDK-specific type detection
|
||||
const preparedNewAttributes = validNewAttributes.map(({ key, value }) => {
|
||||
@@ -298,7 +342,7 @@ export const updateAttributes = async (
|
||||
// Log new attribute creation with their types
|
||||
for (const { key, dataType } of preparedNewAttributes) {
|
||||
logger.info({ environmentId, attributeKey: key, dataType }, "Created new contact attribute");
|
||||
messages.push(`Created new attribute '${key}' with type '${dataType}'`);
|
||||
messages.push({ code: "new_attribute_created", params: { key, dataType } });
|
||||
}
|
||||
|
||||
// Create new attributes since we're under the limit
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TContactAttribute } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import {
|
||||
getContactAttributes,
|
||||
getContactAttributesWithMetadata,
|
||||
getContactAttributesWithKeyInfo,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "./contact-attributes";
|
||||
@@ -31,7 +31,7 @@ const mockAttributes = [
|
||||
{ value: "John", attributeKey: { key: "name", name: "Name" } },
|
||||
] as unknown as TContactAttribute[];
|
||||
|
||||
const mockAttributesWithMetadata = [
|
||||
const mockAttributesWithKeyInfo = [
|
||||
{
|
||||
value: "john@example.com",
|
||||
valueNumber: null,
|
||||
@@ -102,21 +102,21 @@ describe("getContactAttributes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactAttributesWithMetadata", () => {
|
||||
describe("getContactAttributesWithKeyInfo", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns attributes with full metadata", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue(
|
||||
mockAttributesWithMetadata as unknown as Prisma.Result<
|
||||
mockAttributesWithKeyInfo as unknown as Prisma.Result<
|
||||
typeof prisma.contactAttribute,
|
||||
unknown,
|
||||
"findMany"
|
||||
>
|
||||
);
|
||||
|
||||
const result = await getContactAttributesWithMetadata(contactId);
|
||||
const result = await getContactAttributesWithKeyInfo(contactId);
|
||||
|
||||
expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({
|
||||
where: { contactId },
|
||||
@@ -158,7 +158,7 @@ describe("getContactAttributesWithMetadata", () => {
|
||||
test("returns empty array if no attributes", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getContactAttributesWithMetadata(contactId);
|
||||
const result = await getContactAttributesWithKeyInfo(contactId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -189,7 +189,7 @@ describe("getContactAttributesWithMetadata", () => {
|
||||
mixedTypeAttributes as unknown as Prisma.Result<typeof prisma.contactAttribute, unknown, "findMany">
|
||||
);
|
||||
|
||||
const result = await getContactAttributesWithMetadata(contactId);
|
||||
const result = await getContactAttributesWithKeyInfo(contactId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].dataType).toBe("string");
|
||||
@@ -209,14 +209,14 @@ describe("getContactAttributesWithMetadata", () => {
|
||||
});
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getContactAttributesWithMetadata(contactId)).rejects.toThrow(DatabaseError);
|
||||
await expect(getContactAttributesWithKeyInfo(contactId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("rethrows non-Prisma errors", async () => {
|
||||
const genericError = new Error("Generic error");
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getContactAttributesWithMetadata(contactId)).rejects.toThrow("Generic error");
|
||||
await expect(getContactAttributesWithKeyInfo(contactId)).rejects.toThrow("Generic error");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export const getContactAttributes = reactCache(async (contactId: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
export const getContactAttributesWithMetadata = reactCache(async (contactId: string) => {
|
||||
export const getContactAttributesWithKeyInfo = reactCache(async (contactId: string) => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -446,7 +446,7 @@ describe("Contacts Lib", () => {
|
||||
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
});
|
||||
|
||||
test("skips duplicate contacts when duplicateContactsAction is skip", async () => {
|
||||
@@ -469,7 +469,7 @@ describe("Contacts Lib", () => {
|
||||
|
||||
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
});
|
||||
|
||||
test("updates duplicate contacts when duplicateContactsAction is update", async () => {
|
||||
@@ -495,7 +495,7 @@ describe("Contacts Lib", () => {
|
||||
|
||||
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
});
|
||||
|
||||
test("overwrites duplicate contacts when duplicateContactsAction is overwrite", async () => {
|
||||
@@ -522,7 +522,7 @@ describe("Contacts Lib", () => {
|
||||
|
||||
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "overwrite", attributeMap);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
});
|
||||
|
||||
test("throws ValidationError when email is missing", async () => {
|
||||
@@ -574,7 +574,7 @@ describe("Contacts Lib", () => {
|
||||
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
|
||||
|
||||
expect(prisma.contactAttributeKey.createMany).toHaveBeenCalled();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
});
|
||||
|
||||
test("handles case-insensitive attribute keys", async () => {
|
||||
@@ -602,7 +602,7 @@ describe("Contacts Lib", () => {
|
||||
attributeMap
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
});
|
||||
|
||||
test("handles userId conflict in update mode when userId already exists for another contact", async () => {
|
||||
@@ -640,7 +640,7 @@ describe("Contacts Lib", () => {
|
||||
attributeMap
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
expect(prisma.contact.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -680,7 +680,7 @@ describe("Contacts Lib", () => {
|
||||
attributeMap
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalled();
|
||||
expect(prisma.contact.update).toHaveBeenCalled();
|
||||
});
|
||||
@@ -712,7 +712,7 @@ describe("Contacts Lib", () => {
|
||||
attributeMap
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect("contacts" in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -344,31 +344,23 @@ const findInvalidValuesForType = (values: string[], dataType: TContactAttributeD
|
||||
return invalidValues;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an error message for invalid attribute values
|
||||
*/
|
||||
const buildInvalidValuesErrorMessage = (
|
||||
key: string,
|
||||
dataType: TContactAttributeDataType,
|
||||
invalidValues: string[]
|
||||
): string => {
|
||||
const sampleInvalid = invalidValues.slice(0, 3).join(", ");
|
||||
const additionalCount = invalidValues.length - 3;
|
||||
const suffix = additionalCount > 0 ? ` (and ${additionalCount} more)` : "";
|
||||
|
||||
return `Attribute "${key}" is typed as "${dataType}" but CSV contains invalid values: ${sampleInvalid}${suffix}`;
|
||||
};
|
||||
interface TCsvAttributeValidationError {
|
||||
key: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
invalidValues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates attribute values against their types.
|
||||
* - For EXISTING typed attributes: throws ValidationError if values don't match
|
||||
* - For EXISTING typed attributes: returns validation errors
|
||||
* - For NEW attributes: downgrades to string if values are inconsistent
|
||||
*/
|
||||
const validateAndAdjustCsvAttributeTypes = (
|
||||
attributeTypeMap: Map<string, TAttributeTypeInfo>,
|
||||
attributeValuesByKey: Map<string, string[]>
|
||||
): void => {
|
||||
): TCsvAttributeValidationError[] => {
|
||||
const typeValidationWarnings: string[] = [];
|
||||
const validationErrors: TCsvAttributeValidationError[] = [];
|
||||
|
||||
for (const [key, typeInfo] of attributeTypeMap) {
|
||||
if (typeInfo.dataType === "string") continue;
|
||||
@@ -379,8 +371,9 @@ const validateAndAdjustCsvAttributeTypes = (
|
||||
if (invalidValues.length === 0) continue;
|
||||
|
||||
if (typeInfo.isExisting) {
|
||||
// EXISTING typed attribute: fail the upload
|
||||
throw new ValidationError(buildInvalidValuesErrorMessage(key, typeInfo.dataType, invalidValues));
|
||||
// EXISTING typed attribute: collect error
|
||||
validationErrors.push({ key, dataType: typeInfo.dataType, invalidValues });
|
||||
continue;
|
||||
}
|
||||
|
||||
// NEW attribute: downgrade to string
|
||||
@@ -393,6 +386,8 @@ const validateAndAdjustCsvAttributeTypes = (
|
||||
if (typeValidationWarnings.length > 0) {
|
||||
logger.warn({ warnings: typeValidationWarnings }, "Type validation warnings during CSV upload");
|
||||
}
|
||||
|
||||
return validationErrors;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -597,12 +592,16 @@ const handleDuplicateContact = async (
|
||||
});
|
||||
};
|
||||
|
||||
export type TCreateContactsFromCSVResult =
|
||||
| { contacts: TContact[] }
|
||||
| { validationErrors: TCsvAttributeValidationError[] };
|
||||
|
||||
export const createContactsFromCSV = async (
|
||||
csvData: Record<string, string>[],
|
||||
environmentId: string,
|
||||
duplicateContactsAction: "skip" | "update" | "overwrite",
|
||||
attributeMap: Record<string, string>
|
||||
): Promise<TContact[]> => {
|
||||
): Promise<TCreateContactsFromCSVResult> => {
|
||||
validateInputs(
|
||||
[csvData, ZContactCSVUploadResponse],
|
||||
[environmentId, ZId],
|
||||
@@ -658,7 +657,10 @@ export const createContactsFromCSV = async (
|
||||
existingAttributeKeys,
|
||||
lowercaseToActualKeyMap
|
||||
);
|
||||
validateAndAdjustCsvAttributeTypes(attributeTypeMap, attributeValuesByKey);
|
||||
const validationErrors = validateAndAdjustCsvAttributeTypes(attributeTypeMap, attributeValuesByKey);
|
||||
if (validationErrors.length > 0) {
|
||||
return { validationErrors };
|
||||
}
|
||||
|
||||
// Step 5: Create missing attribute keys
|
||||
await createMissingAttributeKeys(
|
||||
@@ -683,7 +685,7 @@ export const createContactsFromCSV = async (
|
||||
const contactPromises = csvData.map((record) => processCsvRecord(record, processingContext));
|
||||
|
||||
const results = await Promise.all(contactPromises);
|
||||
return results.filter((contact): contact is TContact => contact !== null);
|
||||
return { contacts: results.filter((contact): contact is TContact => contact !== null) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -45,13 +45,13 @@ describe("formatAttributeValue", () => {
|
||||
|
||||
describe("number dataType", () => {
|
||||
test("should format integer with locale string", () => {
|
||||
const result = formatAttributeValue(1000, "number");
|
||||
const result = formatAttributeValue(1000, "number", "en-US");
|
||||
// toLocaleString adds commas for thousands in en-US
|
||||
expect(result).toBe("1,000");
|
||||
});
|
||||
|
||||
test("should format large number with locale string", () => {
|
||||
const result = formatAttributeValue(1234567, "number");
|
||||
const result = formatAttributeValue(1234567, "number", "en-US");
|
||||
expect(result).toBe("1,234,567");
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("formatAttributeValue", () => {
|
||||
});
|
||||
|
||||
test("should parse numeric string", () => {
|
||||
const result = formatAttributeValue("1000", "number");
|
||||
const result = formatAttributeValue("1000", "number", "en-US");
|
||||
expect(result).toBe("1,000");
|
||||
});
|
||||
|
||||
@@ -90,20 +90,20 @@ describe("formatAttributeValue", () => {
|
||||
});
|
||||
|
||||
describe("date dataType", () => {
|
||||
test("should format Date object as 'MMM d, yyyy'", () => {
|
||||
test("should format Date object with locale-aware formatting", () => {
|
||||
const date = new Date("2024-06-15T10:30:00.000Z");
|
||||
const result = formatAttributeValue(date, "date");
|
||||
const result = formatAttributeValue(date, "date", "en-US");
|
||||
// Note: The exact format depends on timezone, but should contain these parts
|
||||
expect(result).toMatch(/Jun \d+, 2024/);
|
||||
});
|
||||
|
||||
test("should format ISO date string", () => {
|
||||
const result = formatAttributeValue("2024-01-15", "date");
|
||||
const result = formatAttributeValue("2024-01-15", "date", "en-US");
|
||||
expect(result).toMatch(/Jan \d+, 2024/);
|
||||
});
|
||||
|
||||
test("should format date string with time", () => {
|
||||
const result = formatAttributeValue("2024-12-25T00:00:00.000Z", "date");
|
||||
const result = formatAttributeValue("2024-12-25T00:00:00.000Z", "date", "en-US");
|
||||
expect(result).toMatch(/Dec \d+, 2024/);
|
||||
});
|
||||
|
||||
@@ -114,9 +114,16 @@ describe("formatAttributeValue", () => {
|
||||
|
||||
test("should handle timestamp number", () => {
|
||||
const timestamp = new Date("2024-06-15T10:30:00.000Z").getTime();
|
||||
const result = formatAttributeValue(timestamp, "date");
|
||||
const result = formatAttributeValue(timestamp, "date", "en-US");
|
||||
expect(result).toMatch(/Jun \d+, 2024/);
|
||||
});
|
||||
|
||||
test("should format date in different locale", () => {
|
||||
const date = new Date("2024-06-15T10:30:00.000Z");
|
||||
const result = formatAttributeValue(date, "date", "de-DE");
|
||||
// German format uses different month abbreviation
|
||||
expect(result).toMatch(/Juni?\s+\d+,?\s+2024|15\.\s*Juni?\s*2024/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown/default dataType", () => {
|
||||
@@ -133,7 +140,7 @@ describe("formatAttributeValue", () => {
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should handle very large numbers", () => {
|
||||
const result = formatAttributeValue(999999999999, "number");
|
||||
const result = formatAttributeValue(999999999999, "number", "en-US");
|
||||
expect(result).toBe("999,999,999,999");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { format } from "date-fns";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Formats an attribute value for display based on its data type.
|
||||
* Uses Intl APIs for proper locale-aware formatting.
|
||||
*
|
||||
* @param value - The raw attribute value (string representation from DB)
|
||||
* @param dataType - The data type of the attribute
|
||||
* @param locale - Optional locale string (defaults to browser locale)
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
export const formatAttributeValue = (
|
||||
value: string | number | Date | null | undefined,
|
||||
dataType: TContactAttributeDataType
|
||||
dataType: TContactAttributeDataType,
|
||||
locale?: string
|
||||
): string => {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined || value === "") {
|
||||
@@ -20,9 +22,16 @@ export const formatAttributeValue = (
|
||||
switch (dataType) {
|
||||
case "date": {
|
||||
try {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
// Format as "Jan 15, 2024" for better readability
|
||||
return format(date, "MMM d, yyyy");
|
||||
const date = value instanceof Date ? value : new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
// Use Intl.DateTimeFormat for locale-aware date formatting
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
} catch {
|
||||
// If date parsing fails, return the raw value
|
||||
return String(value);
|
||||
@@ -35,8 +44,7 @@ export const formatAttributeValue = (
|
||||
if (Number.isNaN(num)) {
|
||||
return String(value);
|
||||
}
|
||||
// Use toLocaleString for proper formatting with commas
|
||||
return num.toLocaleString();
|
||||
return num.toLocaleString(locale);
|
||||
}
|
||||
|
||||
case "string":
|
||||
|
||||
@@ -270,14 +270,14 @@ describe("updateContactAttributes", () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
|
||||
vi.mocked(updateAttributes).mockResolvedValue({
|
||||
success: true,
|
||||
messages: ["The email already exists for this environment and was not updated."],
|
||||
messages: [{ code: "email_already_exists", params: {} }],
|
||||
});
|
||||
vi.mocked(getContactAttributes).mockResolvedValue(mockUpdatedAttributes);
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
|
||||
|
||||
const result = await updateContactAttributes(contactId, attributes);
|
||||
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContainEqual({ code: "email_already_exists", params: {} });
|
||||
});
|
||||
|
||||
test("should throw error if contact not found", async () => {
|
||||
|
||||
@@ -2,14 +2,14 @@ import "server-only";
|
||||
import { TContactAttributes, TContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { updateAttributes } from "./attributes";
|
||||
import { TAttributeUpdateMessage, updateAttributes } from "./attributes";
|
||||
import { getContactAttributeKeys } from "./contact-attribute-keys";
|
||||
import { getContactAttributes } from "./contact-attributes";
|
||||
import { getContact } from "./contacts";
|
||||
|
||||
export interface UpdateContactAttributesResult {
|
||||
updatedAttributes: TContactAttributes;
|
||||
messages?: string[];
|
||||
messages?: TAttributeUpdateMessage[];
|
||||
updatedAttributeKeys?: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const updateContactAttributes = async (
|
||||
// If missing, pass empty string but note it in messages
|
||||
const userIdValue = attributes.userId;
|
||||
const userId = userIdValue === null || userIdValue === undefined ? "" : String(userIdValue);
|
||||
const messages: string[] = [];
|
||||
const messages: TAttributeUpdateMessage[] = [];
|
||||
|
||||
// Get current attribute keys before update to detect new ones
|
||||
const currentAttributeKeys = await getContactAttributeKeys(environmentId);
|
||||
|
||||
@@ -2,6 +2,14 @@ import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-k
|
||||
|
||||
type TRawValue = string | number | Date;
|
||||
|
||||
/**
|
||||
* Structured validation error with code and params for i18n
|
||||
*/
|
||||
export interface TAttributeValidationError {
|
||||
code: string;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of attribute value validation
|
||||
*/
|
||||
@@ -16,7 +24,7 @@ export type TAttributeValidationResult =
|
||||
}
|
||||
| {
|
||||
valid: false;
|
||||
error: string;
|
||||
error: TAttributeValidationError;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,7 +76,10 @@ const validateNumberType = (value: TRawValue, attributeKey: string): TAttributeV
|
||||
// Strings are NOT accepted for number attributes (even if they look like numbers)
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a number but received a string. Pass an actual number value (e.g., 123 instead of "123").`,
|
||||
error: {
|
||||
code: "number_type_mismatch",
|
||||
params: { key: attributeKey },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -90,7 +101,10 @@ const validateDateType = (value: TRawValue, attributeKey: string): TAttributeVal
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a valid date. Received: Invalid Date`,
|
||||
error: {
|
||||
code: "date_invalid",
|
||||
params: { key: attributeKey },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -124,13 +138,19 @@ const validateDateType = (value: TRawValue, attributeKey: string): TAttributeVal
|
||||
// String is not in ISO format
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a date in ISO 8601 format (e.g., "2024-01-15" or "2024-01-15T10:30:00.000Z") or a Date object. Received: "${trimmedValue}"`,
|
||||
error: {
|
||||
code: "date_format_invalid",
|
||||
params: { key: attributeKey, value: trimmedValue },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a valid date. Received: ${getTypeName(value)} value '${String(value)}'`,
|
||||
error: {
|
||||
code: "date_unexpected_type",
|
||||
params: { key: attributeKey, type: getTypeName(value), value: String(value) },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -159,3 +179,24 @@ export const validateAndParseAttributeValue = (
|
||||
return validateStringType(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a structured validation error to a human-readable English string.
|
||||
* Used for API/SDK responses.
|
||||
*/
|
||||
const VALIDATION_ERROR_TEMPLATES: Record<string, string> = {
|
||||
number_type_mismatch:
|
||||
"Attribute '{key}' expects a number but received a string. Pass an actual number value (e.g., 123 instead of \"123\").",
|
||||
date_invalid: "Attribute '{key}' expects a valid date. Received: Invalid Date",
|
||||
date_format_invalid:
|
||||
'Attribute \'{key}\' expects a date in ISO 8601 format (e.g., "2024-01-15" or "2024-01-15T10:30:00.000Z") or a Date object. Received: "{value}"',
|
||||
date_unexpected_type: "Attribute '{key}' expects a valid date. Received: {type} value '{value}'",
|
||||
};
|
||||
|
||||
export const formatValidationError = (error: TAttributeValidationError): string => {
|
||||
let template = VALIDATION_ERROR_TEMPLATES[error.code] || error.code;
|
||||
for (const [key, value] of Object.entries(error.params)) {
|
||||
template = template.replaceAll(`{${key}}`, value);
|
||||
}
|
||||
return template;
|
||||
};
|
||||
|
||||
@@ -222,7 +222,7 @@ function AttributeSegmentFilter({
|
||||
}: TAttributeSegmentFilterProps) {
|
||||
const { contactAttributeKey } = resource.root;
|
||||
const { t } = useTranslation();
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator, t);
|
||||
|
||||
const [valueError, setValueError] = useState("");
|
||||
|
||||
@@ -263,7 +263,7 @@ function AttributeSegmentFilter({
|
||||
const operatorArr = availableOperators.map((operator) => {
|
||||
return {
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
name: convertOperatorToText(operator, t),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -388,7 +388,7 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -424,7 +424,7 @@ function AttributeSegmentFilter({
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id} key={operator.id}>
|
||||
<SelectItem title={convertOperatorToTitle(operator.id, t)} value={operator.id} key={operator.id}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -464,8 +464,8 @@ function PersonSegmentFilter({
|
||||
viewOnly,
|
||||
}: TPersonSegmentFilterProps) {
|
||||
const { personIdentifier } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
const { t } = useTranslation();
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator, t);
|
||||
const [valueError, setValueError] = useState("");
|
||||
|
||||
// when the operator changes, we need to check if the value is valid
|
||||
@@ -486,7 +486,7 @@ function PersonSegmentFilter({
|
||||
const operatorArr = PERSON_OPERATORS.map((operator) => {
|
||||
return {
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
name: convertOperatorToText(operator, t),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -555,7 +555,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -586,7 +586,7 @@ function PersonSegmentFilter({
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id} key={operator.id}>
|
||||
<SelectItem title={convertOperatorToTitle(operator.id, t)} value={operator.id} key={operator.id}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -642,7 +642,8 @@ function SegmentSegmentFilter({
|
||||
viewOnly,
|
||||
}: TSegmentSegmentFilterProps) {
|
||||
const { segmentId } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
const { t } = useTranslation();
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator, t);
|
||||
|
||||
const currentSegment = segments.find((segment) => segment.id === segmentId);
|
||||
|
||||
@@ -706,7 +707,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
@@ -754,10 +755,10 @@ function DeviceFilter({
|
||||
}: TDeviceFilterProps) {
|
||||
const { value } = resource;
|
||||
const { t } = useTranslation();
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator, t);
|
||||
const operatorArr = DEVICE_OPERATORS.map((operator) => ({
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
name: convertOperatorToText(operator, t),
|
||||
}));
|
||||
|
||||
const updateOperatorInSegment = (filterId: string, newOperator: TDeviceOperator) => {
|
||||
|
||||
@@ -92,41 +92,56 @@ describe("Segment Utils", () => {
|
||||
});
|
||||
|
||||
test("convertOperatorToText", () => {
|
||||
expect(convertOperatorToText("equals")).toBe("=");
|
||||
expect(convertOperatorToText("notEquals")).toBe("!=");
|
||||
expect(convertOperatorToText("lessThan")).toBe("<");
|
||||
expect(convertOperatorToText("lessEqual")).toBe("<=");
|
||||
expect(convertOperatorToText("greaterThan")).toBe(">");
|
||||
expect(convertOperatorToText("greaterEqual")).toBe(">=");
|
||||
expect(convertOperatorToText("isSet")).toBe("is set");
|
||||
expect(convertOperatorToText("isNotSet")).toBe("is not set");
|
||||
expect(convertOperatorToText("contains")).toBe("contains ");
|
||||
expect(convertOperatorToText("doesNotContain")).toBe("does not contain");
|
||||
expect(convertOperatorToText("startsWith")).toBe("starts with");
|
||||
expect(convertOperatorToText("endsWith")).toBe("ends with");
|
||||
expect(convertOperatorToText("userIsIn")).toBe("User is in");
|
||||
expect(convertOperatorToText("userIsNotIn")).toBe("User is not in");
|
||||
// Mock t() that returns the i18n key for verification
|
||||
const t = (key: string) => key;
|
||||
|
||||
expect(convertOperatorToText("equals", t)).toBe("=");
|
||||
expect(convertOperatorToText("notEquals", t)).toBe("!=");
|
||||
expect(convertOperatorToText("lessThan", t)).toBe("<");
|
||||
expect(convertOperatorToText("lessEqual", t)).toBe("<=");
|
||||
expect(convertOperatorToText("greaterThan", t)).toBe(">");
|
||||
expect(convertOperatorToText("greaterEqual", t)).toBe(">=");
|
||||
expect(convertOperatorToText("isSet", t)).toBe("environments.segments.operator_is_set");
|
||||
expect(convertOperatorToText("isNotSet", t)).toBe("environments.segments.operator_is_not_set");
|
||||
expect(convertOperatorToText("contains", t)).toBe("environments.segments.operator_contains");
|
||||
expect(convertOperatorToText("doesNotContain", t)).toBe(
|
||||
"environments.segments.operator_does_not_contain"
|
||||
);
|
||||
expect(convertOperatorToText("startsWith", t)).toBe("environments.segments.operator_starts_with");
|
||||
expect(convertOperatorToText("endsWith", t)).toBe("environments.segments.operator_ends_with");
|
||||
expect(convertOperatorToText("userIsIn", t)).toBe("environments.segments.operator_user_is_in");
|
||||
expect(convertOperatorToText("userIsNotIn", t)).toBe("environments.segments.operator_user_is_not_in");
|
||||
// @ts-expect-error - testing default case
|
||||
expect(convertOperatorToText("unknown")).toBe("unknown");
|
||||
expect(convertOperatorToText("unknown", t)).toBe("unknown");
|
||||
});
|
||||
|
||||
test("convertOperatorToTitle", () => {
|
||||
expect(convertOperatorToTitle("equals")).toBe("Equals");
|
||||
expect(convertOperatorToTitle("notEquals")).toBe("Not equals to");
|
||||
expect(convertOperatorToTitle("lessThan")).toBe("Less than");
|
||||
expect(convertOperatorToTitle("lessEqual")).toBe("Less than or equal to");
|
||||
expect(convertOperatorToTitle("greaterThan")).toBe("Greater than");
|
||||
expect(convertOperatorToTitle("greaterEqual")).toBe("Greater than or equal to");
|
||||
expect(convertOperatorToTitle("isSet")).toBe("Is set");
|
||||
expect(convertOperatorToTitle("isNotSet")).toBe("Is not set");
|
||||
expect(convertOperatorToTitle("contains")).toBe("Contains");
|
||||
expect(convertOperatorToTitle("doesNotContain")).toBe("Does not contain");
|
||||
expect(convertOperatorToTitle("startsWith")).toBe("Starts with");
|
||||
expect(convertOperatorToTitle("endsWith")).toBe("Ends with");
|
||||
expect(convertOperatorToTitle("userIsIn")).toBe("User is in");
|
||||
expect(convertOperatorToTitle("userIsNotIn")).toBe("User is not in");
|
||||
const t = (key: string) => key;
|
||||
|
||||
expect(convertOperatorToTitle("equals", t)).toBe("environments.segments.operator_title_equals");
|
||||
expect(convertOperatorToTitle("notEquals", t)).toBe("environments.segments.operator_title_not_equals");
|
||||
expect(convertOperatorToTitle("lessThan", t)).toBe("environments.segments.operator_title_less_than");
|
||||
expect(convertOperatorToTitle("lessEqual", t)).toBe("environments.segments.operator_title_less_equal");
|
||||
expect(convertOperatorToTitle("greaterThan", t)).toBe(
|
||||
"environments.segments.operator_title_greater_than"
|
||||
);
|
||||
expect(convertOperatorToTitle("greaterEqual", t)).toBe(
|
||||
"environments.segments.operator_title_greater_equal"
|
||||
);
|
||||
expect(convertOperatorToTitle("isSet", t)).toBe("environments.segments.operator_title_is_set");
|
||||
expect(convertOperatorToTitle("isNotSet", t)).toBe("environments.segments.operator_title_is_not_set");
|
||||
expect(convertOperatorToTitle("contains", t)).toBe("environments.segments.operator_title_contains");
|
||||
expect(convertOperatorToTitle("doesNotContain", t)).toBe(
|
||||
"environments.segments.operator_title_does_not_contain"
|
||||
);
|
||||
expect(convertOperatorToTitle("startsWith", t)).toBe("environments.segments.operator_title_starts_with");
|
||||
expect(convertOperatorToTitle("endsWith", t)).toBe("environments.segments.operator_title_ends_with");
|
||||
expect(convertOperatorToTitle("userIsIn", t)).toBe("environments.segments.operator_title_user_is_in");
|
||||
expect(convertOperatorToTitle("userIsNotIn", t)).toBe(
|
||||
"environments.segments.operator_title_user_is_not_in"
|
||||
);
|
||||
// @ts-expect-error - testing default case
|
||||
expect(convertOperatorToTitle("unknown")).toBe("unknown");
|
||||
expect(convertOperatorToTitle("unknown", t)).toBe("unknown");
|
||||
});
|
||||
|
||||
test("addFilterBelow", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
TAllOperators,
|
||||
TAttributeOperator,
|
||||
@@ -21,7 +22,9 @@ export const isResourceFilter = (resource: TSegmentFilter | TBaseFilters): resou
|
||||
return (resource as TSegmentFilter).root !== undefined;
|
||||
};
|
||||
|
||||
export const convertOperatorToText = (operator: TAllOperators) => {
|
||||
// type TTranslate = (key: string) => string;
|
||||
|
||||
export const convertOperatorToText = (operator: TAllOperators, t: TFunction) => {
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return "=";
|
||||
@@ -36,80 +39,80 @@ export const convertOperatorToText = (operator: TAllOperators) => {
|
||||
case "greaterEqual":
|
||||
return ">=";
|
||||
case "isSet":
|
||||
return "is set";
|
||||
return t("environments.segments.operator_is_set");
|
||||
case "isNotSet":
|
||||
return "is not set";
|
||||
return t("environments.segments.operator_is_not_set");
|
||||
case "contains":
|
||||
return "contains ";
|
||||
return t("environments.segments.operator_contains");
|
||||
case "doesNotContain":
|
||||
return "does not contain";
|
||||
return t("environments.segments.operator_does_not_contain");
|
||||
case "startsWith":
|
||||
return "starts with";
|
||||
return t("environments.segments.operator_starts_with");
|
||||
case "endsWith":
|
||||
return "ends with";
|
||||
return t("environments.segments.operator_ends_with");
|
||||
case "userIsIn":
|
||||
return "User is in";
|
||||
return t("environments.segments.operator_user_is_in");
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
return t("environments.segments.operator_user_is_not_in");
|
||||
case "isOlderThan":
|
||||
return "is older than";
|
||||
return t("environments.segments.operator_is_older_than");
|
||||
case "isNewerThan":
|
||||
return "is newer than";
|
||||
return t("environments.segments.operator_is_newer_than");
|
||||
case "isBefore":
|
||||
return "is before";
|
||||
return t("environments.segments.operator_is_before");
|
||||
case "isAfter":
|
||||
return "is after";
|
||||
return t("environments.segments.operator_is_after");
|
||||
case "isBetween":
|
||||
return "is between";
|
||||
return t("environments.segments.operator_is_between");
|
||||
case "isSameDay":
|
||||
return "is same day";
|
||||
return t("environments.segments.operator_is_same_day");
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
};
|
||||
|
||||
export const convertOperatorToTitle = (operator: TAllOperators) => {
|
||||
export const convertOperatorToTitle = (operator: TAllOperators, t: TFunction) => {
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return "Equals";
|
||||
return t("environments.segments.operator_title_equals");
|
||||
case "notEquals":
|
||||
return "Not equals to";
|
||||
return t("environments.segments.operator_title_not_equals");
|
||||
case "lessThan":
|
||||
return "Less than";
|
||||
return t("environments.segments.operator_title_less_than");
|
||||
case "lessEqual":
|
||||
return "Less than or equal to";
|
||||
return t("environments.segments.operator_title_less_equal");
|
||||
case "greaterThan":
|
||||
return "Greater than";
|
||||
return t("environments.segments.operator_title_greater_than");
|
||||
case "greaterEqual":
|
||||
return "Greater than or equal to";
|
||||
return t("environments.segments.operator_title_greater_equal");
|
||||
case "isSet":
|
||||
return "Is set";
|
||||
return t("environments.segments.operator_title_is_set");
|
||||
case "isNotSet":
|
||||
return "Is not set";
|
||||
return t("environments.segments.operator_title_is_not_set");
|
||||
case "contains":
|
||||
return "Contains";
|
||||
return t("environments.segments.operator_title_contains");
|
||||
case "doesNotContain":
|
||||
return "Does not contain";
|
||||
return t("environments.segments.operator_title_does_not_contain");
|
||||
case "startsWith":
|
||||
return "Starts with";
|
||||
return t("environments.segments.operator_title_starts_with");
|
||||
case "endsWith":
|
||||
return "Ends with";
|
||||
return t("environments.segments.operator_title_ends_with");
|
||||
case "userIsIn":
|
||||
return "User is in";
|
||||
return t("environments.segments.operator_title_user_is_in");
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
return t("environments.segments.operator_title_user_is_not_in");
|
||||
case "isOlderThan":
|
||||
return "Is older than";
|
||||
return t("environments.segments.operator_title_is_older_than");
|
||||
case "isNewerThan":
|
||||
return "Is newer than";
|
||||
return t("environments.segments.operator_title_is_newer_than");
|
||||
case "isBefore":
|
||||
return "Is before";
|
||||
return t("environments.segments.operator_title_is_before");
|
||||
case "isAfter":
|
||||
return "Is after";
|
||||
return t("environments.segments.operator_title_is_after");
|
||||
case "isBetween":
|
||||
return "Is between";
|
||||
return t("environments.segments.operator_title_is_between");
|
||||
case "isSameDay":
|
||||
return "Is same day";
|
||||
return t("environments.segments.operator_title_is_same_day");
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@ export const ZContactAttribute = z.object({
|
||||
});
|
||||
export type TContactAttribute = z.infer<typeof ZContactAttribute>;
|
||||
|
||||
// For display purposes (attributes are always stored as strings in value column)
|
||||
export const ZContactAttributes = z.record(z.string());
|
||||
export type TContactAttributes = z.infer<typeof ZContactAttributes>;
|
||||
|
||||
// For SDK input - accepts string or number (Date is converted to ISO string by SDK)
|
||||
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number()]));
|
||||
export type TContactAttributesInput = z.infer<typeof ZContactAttributesInput>;
|
||||
|
||||
Reference in New Issue
Block a user