Import - Improve phone validation (#12901)

Context : 
- Phones import is a bit complex if not all subfields are provided.
- Phones subfield validation are absent or different from BE validation.

Solution : 
- Normalize callingCode and countryCode validation (BE/FE)
- Ease phone import if only phoneNumber is provided
This commit is contained in:
Etienne
2025-07-04 23:07:24 +02:00
committed by GitHub
parent 1386f344dd
commit e8905be71a
10 changed files with 208 additions and 145 deletions

View File

@ -405,4 +405,39 @@ describe('buildRecordFromImportedStructuredRow', () => {
ratingField: '4', ratingField: '4',
}); });
}); });
it('should handle case where user provides only a primaryPhoneNumber without calling code', () => {
const importedStructuredRow: ImportedStructuredRow<string> = {
'Primary Phone Number (phoneField)': '5550123',
};
const fields: FieldMetadataItem[] = [
{
id: '13',
name: 'phoneField',
label: 'Phone Field',
type: FieldMetadataType.PHONES,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconPhone',
description: null,
},
];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fields,
});
expect(result).toEqual({
phoneField: {
primaryPhoneNumber: '5550123',
primaryPhoneCallingCode: '+1',
},
});
});
}); });

View File

@ -1,14 +1,16 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel'; import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
import { ImportedStructuredRow } from '@/spreadsheet-import/types'; import { ImportedStructuredRow } from '@/spreadsheet-import/types';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod'; import { z } from 'zod';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { castToString } from '~/utils/castToString'; import { castToString } from '~/utils/castToString';
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
import { isEmptyObject } from '~/utils/isEmptyObject'; import { isEmptyObject } from '~/utils/isEmptyObject';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type BuildRecordFromImportedStructuredRowArgs = { type BuildRecordFromImportedStructuredRowArgs = {
importedStructuredRow: ImportedStructuredRow<any>; importedStructuredRow: ImportedStructuredRow<any>;
@ -16,23 +18,14 @@ type BuildRecordFromImportedStructuredRowArgs = {
}; };
const buildCompositeFieldRecord = ( const buildCompositeFieldRecord = (
fieldName: string, field: FieldMetadataItem,
importedStructuredRow: ImportedStructuredRow<any>, importedStructuredRow: ImportedStructuredRow<any>,
compositeFieldConfig: Record< compositeFieldConfig: Record<string, ((value: any) => any) | undefined>,
string,
{
labelKey: string;
transform?: (value: any) => any;
}
>,
): Record<string, any> | undefined => { ): Record<string, any> | undefined => {
const compositeFieldRecord = Object.entries(compositeFieldConfig).reduce( const compositeFieldRecord = Object.entries(compositeFieldConfig).reduce(
( (acc, [compositeFieldKey, transform]) => {
acc,
[compositeFieldKey, { labelKey: compositeFieldLabelKey, transform }],
) => {
const value = const value =
importedStructuredRow[`${compositeFieldLabelKey} (${fieldName})`]; importedStructuredRow[getSubFieldOptionKey(field, compositeFieldKey)];
return isDefined(value) return isDefined(value)
? { ...acc, [compositeFieldKey]: transform?.(value) || value } ? { ...acc, [compositeFieldKey]: transform?.(value) || value }
@ -106,123 +99,49 @@ export const buildRecordFromImportedStructuredRow = ({
const recordToBuild: Record<string, any> = {}; const recordToBuild: Record<string, any> = {};
const { const COMPOSITE_FIELD_TRANSFORM_CONFIGS = {
ADDRESS: {
addressCity: addressCityLabel,
addressCountry: addressCountryLabel,
addressPostcode: addressPostcodeLabel,
addressState: addressStateLabel,
addressStreet1: addressStreet1Label,
addressStreet2: addressStreet2Label,
},
CURRENCY: {
amountMicros: amountMicrosLabel,
currencyCode: currencyCodeLabel,
},
FULL_NAME: { firstName: firstNameLabel, lastName: lastNameLabel },
LINKS: {
primaryLinkUrl: primaryLinkUrlLabel,
primaryLinkLabel: primaryLinkLabelLabel,
secondaryLinks: secondaryLinksLabel,
},
EMAILS: {
primaryEmail: primaryEmailLabel,
additionalEmails: additionalEmailsLabel,
},
PHONES: {
primaryPhoneNumber: primaryPhoneNumberLabel,
primaryPhoneCountryCode: primaryPhoneCountryCodeLabel,
primaryPhoneCallingCode: primaryPhoneCallingCodeLabel,
additionalPhones: additionalPhonesLabel,
},
RICH_TEXT_V2: { blocknote: blocknoteLabel, markdown: markdownLabel },
} = COMPOSITE_FIELD_SUB_FIELD_LABELS;
const COMPOSITE_FIELD_CONFIGS = {
[FieldMetadataType.CURRENCY]: { [FieldMetadataType.CURRENCY]: {
amountMicros: { amountMicros: (value: any) =>
labelKey: amountMicrosLabel, convertCurrencyAmountToCurrencyMicros(Number(value)),
transform: (value: any) => currencyCode: undefined,
convertCurrencyAmountToCurrencyMicros(Number(value)),
},
currencyCode: { labelKey: currencyCodeLabel },
}, },
[FieldMetadataType.ADDRESS]: { [FieldMetadataType.ADDRESS]: {
addressStreet1: { addressStreet1: castToString,
labelKey: addressStreet1Label, addressStreet2: castToString,
transform: castToString, addressCity: castToString,
}, addressPostcode: castToString,
addressStreet2: { addressState: castToString,
labelKey: addressStreet2Label, addressCountry: castToString,
transform: castToString,
},
addressCity: { labelKey: addressCityLabel, transform: castToString },
addressPostcode: {
labelKey: addressPostcodeLabel,
transform: castToString,
},
addressState: { labelKey: addressStateLabel, transform: castToString },
addressCountry: {
labelKey: addressCountryLabel,
transform: castToString,
},
}, },
[FieldMetadataType.LINKS]: { [FieldMetadataType.LINKS]: {
primaryLinkLabel: { primaryLinkLabel: castToString,
labelKey: primaryLinkLabelLabel, primaryLinkUrl: castToString,
transform: castToString, secondaryLinks: linkArrayJSONSchema.parse,
},
primaryLinkUrl: {
labelKey: primaryLinkUrlLabel,
transform: castToString,
},
secondaryLinks: {
labelKey: secondaryLinksLabel,
transform: linkArrayJSONSchema.parse,
},
}, },
[FieldMetadataType.PHONES]: { [FieldMetadataType.PHONES]: {
primaryPhoneCountryCode: { primaryPhoneCountryCode: castToString,
labelKey: primaryPhoneCountryCodeLabel, primaryPhoneNumber: castToString,
transform: castToString, primaryPhoneCallingCode: castToString,
}, additionalPhones: phoneArrayJSONSchema.parse,
primaryPhoneNumber: {
labelKey: primaryPhoneNumberLabel,
transform: castToString,
},
primaryPhoneCallingCode: {
labelKey: primaryPhoneCallingCodeLabel,
transform: castToString,
},
additionalPhones: {
labelKey: additionalPhonesLabel,
transform: phoneArrayJSONSchema.parse,
},
}, },
[FieldMetadataType.RICH_TEXT_V2]: { [FieldMetadataType.RICH_TEXT_V2]: {
blocknote: { labelKey: blocknoteLabel, transform: castToString }, blocknote: castToString,
markdown: { labelKey: markdownLabel, transform: castToString }, markdown: castToString,
}, },
[FieldMetadataType.EMAILS]: { [FieldMetadataType.EMAILS]: {
primaryEmail: { labelKey: primaryEmailLabel, transform: castToString }, primaryEmail: castToString,
additionalEmails: { additionalEmails: stringArrayJSONSchema.parse,
labelKey: additionalEmailsLabel,
transform: stringArrayJSONSchema.parse,
},
}, },
[FieldMetadataType.FULL_NAME]: { [FieldMetadataType.FULL_NAME]: {
firstName: { labelKey: firstNameLabel }, firstName: undefined,
lastName: { labelKey: lastNameLabel }, lastName: undefined,
}, },
[FieldMetadataType.ACTOR]: { [FieldMetadataType.ACTOR]: {
source: { labelKey: 'source', transform: () => 'IMPORT' }, source: () => 'IMPORT',
context: { labelKey: 'context', transform: () => ({}) }, context: () => ({}),
}, },
}; };
@ -233,20 +152,83 @@ export const buildRecordFromImportedStructuredRow = ({
case FieldMetadataType.CURRENCY: case FieldMetadataType.CURRENCY:
case FieldMetadataType.ADDRESS: case FieldMetadataType.ADDRESS:
case FieldMetadataType.LINKS: case FieldMetadataType.LINKS:
case FieldMetadataType.PHONES:
case FieldMetadataType.RICH_TEXT_V2: case FieldMetadataType.RICH_TEXT_V2:
case FieldMetadataType.EMAILS: case FieldMetadataType.EMAILS:
case FieldMetadataType.FULL_NAME: { case FieldMetadataType.FULL_NAME: {
const compositeData = buildCompositeFieldRecord( const compositeData = buildCompositeFieldRecord(
field.name, field,
importedStructuredRow, importedStructuredRow,
COMPOSITE_FIELD_CONFIGS[field.type], COMPOSITE_FIELD_TRANSFORM_CONFIGS[field.type],
); );
if (isDefined(compositeData)) { if (isDefined(compositeData)) {
recordToBuild[field.name] = compositeData; recordToBuild[field.name] = compositeData;
} }
break; break;
} }
case FieldMetadataType.PHONES: {
const compositeData = buildCompositeFieldRecord(
field,
importedStructuredRow,
COMPOSITE_FIELD_TRANSFORM_CONFIGS[field.type],
);
if (!isDefined(compositeData)) {
break;
}
recordToBuild[field.name] = compositeData;
const primaryPhoneNumber =
importedStructuredRow[
getSubFieldOptionKey(field, 'primaryPhoneNumber')
];
const primaryPhoneCallingCode =
importedStructuredRow[
getSubFieldOptionKey(field, 'primaryPhoneCallingCode')
];
const hasUserProvidedPrimaryPhoneNumberWithoutCallingCode =
isDefined(primaryPhoneNumber) &&
(!isDefined(primaryPhoneCallingCode) ||
!isNonEmptyString(primaryPhoneCallingCode));
// To meet backend requirements, handle case where user provides only a primaryPhoneNumber without calling code
if (hasUserProvidedPrimaryPhoneNumberWithoutCallingCode) {
const primaryPhoneCountryCode =
importedStructuredRow[
getSubFieldOptionKey(field, 'primaryPhoneCountryCode')
];
const hasUserProvidedPrimaryPhoneCountryCode =
isDefined(primaryPhoneCountryCode) &&
isNonEmptyString(primaryPhoneCountryCode);
try {
const {
number: parsedNumber,
countryCallingCode: parsedCountryCallingCode,
} = parsePhoneNumberWithError(
primaryPhoneNumber as string,
hasUserProvidedPrimaryPhoneCountryCode
? (primaryPhoneCountryCode as CountryCode)
: undefined,
);
recordToBuild[field.name] = {
primaryPhoneNumber: parsedNumber,
primaryPhoneCallingCode: `+${parsedCountryCallingCode}`,
};
} catch {
recordToBuild[field.name] = {
primaryPhoneNumber,
primaryPhoneCallingCode:
stripSimpleQuotesFromString(
field?.defaultValue?.primaryPhoneCallingCode,
) || '+1',
};
}
}
break;
}
case FieldMetadataType.BOOLEAN: case FieldMetadataType.BOOLEAN:
recordToBuild[field.name] = recordToBuild[field.name] =
importedFieldValue === 'true' || importedFieldValue === true; importedFieldValue === 'true' || importedFieldValue === true;

View File

@ -4,7 +4,14 @@ import { emailSchema } from '@/object-record/record-field/validation-schemas/ema
import { SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types'; import { SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { isDate, isString } from '@sniptt/guards'; import { isDate, isString } from '@sniptt/guards';
import { absoluteUrlSchema, isDefined, isValidUuid } from 'twenty-shared/utils'; import { parsePhoneNumberWithError } from 'libphonenumber-js';
import {
absoluteUrlSchema,
getCountryCodesForCallingCode,
isDefined,
isValidCountryCode,
isValidUuid,
} from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
const getNumberValidationDefinition = ( const getNumberValidationDefinition = (
@ -16,6 +23,20 @@ const getNumberValidationDefinition = (
level: 'error', level: 'error',
}); });
const isValidPhoneNumber = (value: string) => {
try {
return isDefined(
parsePhoneNumberWithError(value, { defaultCallingCode: '1' }),
);
} catch {
return false;
}
};
const isValidCallingCode = (value: string) => {
return getCountryCodesForCallingCode(value).length > 0;
};
export const getSpreadSheetFieldValidationDefinitions = ( export const getSpreadSheetFieldValidationDefinitions = (
type: FieldMetadataType, type: FieldMetadataType,
fieldName: string, fieldName: string,
@ -143,9 +164,27 @@ export const getSpreadSheetFieldValidationDefinitions = (
case 'primaryPhoneNumber': case 'primaryPhoneNumber':
return [ return [
{ {
rule: 'regex', rule: 'function',
value: '^[0-9]+$', isValid: isValidPhoneNumber,
errorMessage: `${fieldName} ${t`must contain only numbers`}`, errorMessage: `${fieldName} ${t`is not a valid phone number`}`,
level: 'error',
},
];
case 'primaryPhoneCallingCode':
return [
{
rule: 'function',
isValid: isValidCallingCode,
errorMessage: `${fieldName} ${t`is not a valid calling code`}`,
level: 'error',
},
];
case 'primaryPhoneCountryCode':
return [
{
rule: 'function',
isValid: isValidCountryCode,
errorMessage: `${fieldName} ${t`is not a valid country code`}`,
level: 'error', level: 'error',
}, },
]; ];
@ -165,10 +204,9 @@ export const getSpreadSheetFieldValidationDefinitions = (
callingCode: string; callingCode: string;
countryCode: string; countryCode: string;
}) => }) =>
isDefined(phone.number) && isValidPhoneNumber(phone.number) &&
/^[0-9]+$/.test(phone.number) && isValidCallingCode(phone.callingCode) &&
isDefined(phone.callingCode) && isValidCountryCode(phone.countryCode),
isDefined(phone.countryCode),
); );
} catch { } catch {
return false; return false;

View File

@ -2,13 +2,12 @@ import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { import {
CountryCallingCode, CountryCallingCode,
CountryCode,
getCountries,
getCountryCallingCode,
parsePhoneNumberWithError, parsePhoneNumberWithError,
} from 'libphonenumber-js'; } from 'libphonenumber-js';
import { import {
getCountryCodesForCallingCode,
isDefined, isDefined,
isValidCountryCode,
parseJson, parseJson,
removeUndefinedFields, removeUndefinedFields,
} from 'twenty-shared/utils'; } from 'twenty-shared/utils';
@ -22,8 +21,6 @@ import {
PhonesMetadata, PhonesMetadata,
} from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type'; } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
const ALL_COUNTRIES_CODE = getCountries();
export type PhonesFieldGraphQLInput = export type PhonesFieldGraphQLInput =
| Partial< | Partial<
Omit<PhonesMetadata, 'additionalPhones'> & { Omit<PhonesMetadata, 'additionalPhones'> & {
@ -38,22 +35,6 @@ type AdditionalPhoneMetadataWithNumber = Partial<AdditionalPhoneMetadata> &
const removePlusFromString = (str: string) => str.replace(/\+/g, ''); const removePlusFromString = (str: string) => str.replace(/\+/g, '');
const isValidCountryCode = (input: string): input is CountryCode => {
return ALL_COUNTRIES_CODE.includes(input as unknown as CountryCode);
};
const getCountryCodesForCallingCode = (callingCode: string) => {
const cleanCallingCode = callingCode.startsWith('+')
? callingCode.slice(1)
: callingCode;
return ALL_COUNTRIES_CODE.filter((country) => {
const countryCallingCode = getCountryCallingCode(country);
return countryCallingCode === cleanCallingCode;
});
};
const validatePrimaryPhoneCountryCodeAndCallingCode = ({ const validatePrimaryPhoneCountryCodeAndCallingCode = ({
callingCode, callingCode,
countryCode, countryCode,

View File

@ -22,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@sniptt/guards": "^0.2.0", "@sniptt/guards": "^0.2.0",
"libphonenumber-js": "^1.10.26",
"zod": "3.23.8" "zod": "3.23.8"
}, },
"preconstruct": { "preconstruct": {

View File

@ -33,3 +33,5 @@ export { isValidLocale } from './validation/isValidLocale';
export { isValidUuid } from './validation/isValidUuid'; export { isValidUuid } from './validation/isValidUuid';
export { isValidVariable } from './validation/isValidVariable'; export { isValidVariable } from './validation/isValidVariable';
export { normalizeLocale } from './validation/normalizeLocale'; export { normalizeLocale } from './validation/normalizeLocale';
export { getCountryCodesForCallingCode } from './validation/phones-value/getCountryCodesForCallingCode';
export { isValidCountryCode } from './validation/phones-value/isValidCountryCode';

View File

@ -0,0 +1,15 @@
import { getCountries, getCountryCallingCode } from 'libphonenumber-js';
const ALL_COUNTRIES_CODE = getCountries();
export const getCountryCodesForCallingCode = (callingCode: string) => {
const cleanCallingCode = callingCode.startsWith('+')
? callingCode.slice(1)
: callingCode;
return ALL_COUNTRIES_CODE.filter((country) => {
const countryCallingCode = getCountryCallingCode(country);
return countryCallingCode === cleanCallingCode;
});
};

View File

@ -0,0 +1 @@
export * from './isValidCountryCode';

View File

@ -0,0 +1,7 @@
import { CountryCode, getCountries } from 'libphonenumber-js';
const ALL_COUNTRIES_CODE = getCountries();
export const isValidCountryCode = (input: string): input is CountryCode => {
return ALL_COUNTRIES_CODE.includes(input as unknown as CountryCode);
};

View File

@ -56856,6 +56856,7 @@ __metadata:
"@types/babel__preset-env": "npm:^7" "@types/babel__preset-env": "npm:^7"
babel-plugin-module-resolver: "npm:^5.0.2" babel-plugin-module-resolver: "npm:^5.0.2"
glob: "npm:^11.0.1" glob: "npm:^11.0.1"
libphonenumber-js: "npm:^1.10.26"
tsx: "npm:^4.19.3" tsx: "npm:^4.19.3"
zod: "npm:3.23.8" zod: "npm:3.23.8"
languageName: unknown languageName: unknown