Transform record phone field metadata (#12706)
# Introduction close https://github.com/twentyhq/twenty/issues/12343 Adding a transform step for any field phone in order to infer country code and calling code from the number if they're provided ## Edges cases ```ts RecordTransformerExceptionCode.INVALID_PHONE_NUMBER: RecordTransformerExceptionCode.INVALID_PHONE_COUNTRY_CODE: RecordTransformerExceptionCode.CONFLICTING_PHONE_COUNTRY_CODE: RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE: RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE: RecordTransformerExceptionCode.INVALID_PHONE_CALLING_CODE: RecordTransformerExceptionCode.INVALID_URL: ``` ## Coverage Note: Will handle REST api integration testing pivot and UPDATE operation later in the afternoon, critical bug appeared that I prefer handling before improving this PR coverage, also would be too many updates Note2: Haven't fuzzed all of the string inputs, would seem overkill for such a use case, to be debated ```ts PASS test/integration/metadata/suites/field-metadata/phone/create-one-field-metadata-phone.integration-spec.ts (23.609 s) Phone field metadata tests suite ✓ It should succeed create primary phone field (1397 ms) ✓ It should succeed create primary phone field with number and other information (930 ms) ✓ It should succeed create primary phone field with full international format and other information (893 ms) ✓ It should succeed create primary phone field with full international and infer other information from it but not the countryCode as its shared (825 ms) ✓ It should succeed create primary phone field with full international and infer other information from it (818 ms) ✓ It should succeed create primary phone field with empty payload (827 ms) ✓ It should succeed create additional phone field with number and other information (894 ms) ✓ It should succeed create additional phone field with full international format and other information (1024 ms) ✓ It should succeed create additional phone field with full international and infer other information from it but not the countryCode as its shared (808 ms) ✓ It should succeed create additional phone field with full international and infer other information from it (751 ms) ✓ It should succeed create additional phone field with empty payload (739 ms) ✓ It should fail to create primary phone field without country or calling code at all (776 ms) ✓ It should fail to create primary phone field with invalid country code (782 ms) ✓ It should fail to create primary phone field with invalid calling code (858 ms) ✓ It should fail to create primary phone field with conflicting country code and calling code (872 ms) ✓ It should fail to create primary phone field with invalid phone number format (1489 ms) ✓ It should fail to create primary phone field with conflicting phone number country code (1425 ms) ✓ It should fail to create primary phone field with conflicting phone number calling code (1553 ms) ✓ It should fail to create primary phone field without country or calling code at all (814 ms) ✓ It should fail to create primary phone field with invalid country code (813 ms) ✓ It should fail to create primary phone field with invalid calling code (742 ms) ✓ It should fail to create primary phone field with conflicting country code and calling code (783 ms) ✓ It should fail to create primary phone field with invalid phone number format (731 ms) ✓ It should fail to create primary phone field with conflicting phone number country code (947 ms) ✓ It should fail to create primary phone field with conflicting phone number calling code (822 ms) Test Suites: 1 passed, 1 total Tests: 25 passed, 25 total Snapshots: 14 passed, 14 total Time: 23.627 s ```
This commit is contained in:
@ -9,4 +9,10 @@ export class RecordTransformerException extends CustomException {
|
||||
|
||||
export enum RecordTransformerExceptionCode {
|
||||
INVALID_URL = 'INVALID_URL',
|
||||
INVALID_PHONE_NUMBER = 'INVALID_PHONE_NUMBER',
|
||||
INVALID_PHONE_COUNTRY_CODE = 'INVALID_PHONE_COUNTRY_CODE',
|
||||
INVALID_PHONE_CALLING_CODE = 'INVALID_PHONE_CALLING_CODE',
|
||||
CONFLICTING_PHONE_COUNTRY_CODE = 'CONFLICTING_PHONE_COUNTRY_CODE',
|
||||
CONFLICTING_PHONE_CALLING_CODE = 'CONFLICTING_PHONE_CALLING_CODE',
|
||||
CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE = 'CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE',
|
||||
}
|
||||
|
||||
@ -5,10 +5,8 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||
|
||||
import {
|
||||
LinksFieldGraphQLInput,
|
||||
transformLinksValue,
|
||||
} from 'src/engine/core-modules/record-transformer/utils/transform-links-value.util';
|
||||
import { transformLinksValue } from 'src/engine/core-modules/record-transformer/utils/transform-links-value.util';
|
||||
import { transformPhonesValue } from 'src/engine/core-modules/record-transformer/utils/transform-phones-value.util';
|
||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||
import {
|
||||
RichTextV2Metadata,
|
||||
@ -86,9 +84,11 @@ export class RecordInputTransformerService {
|
||||
case FieldMetadataType.RICH_TEXT_V2:
|
||||
return this.transformRichTextV2Value(value);
|
||||
case FieldMetadataType.LINKS:
|
||||
return transformLinksValue(value as LinksFieldGraphQLInput);
|
||||
return transformLinksValue(value);
|
||||
case FieldMetadataType.EMAILS:
|
||||
return this.transformEmailsValue(value);
|
||||
case FieldMetadataType.PHONES:
|
||||
return transformPhonesValue({ input: value });
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
@ -132,7 +132,6 @@ export class RecordInputTransformerService {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private transformEmailsValue(value: any): any {
|
||||
if (!value) {
|
||||
|
||||
@ -10,6 +10,12 @@ export const recordTransformerGraphqlApiExceptionHandler = (
|
||||
error: RecordTransformerException,
|
||||
) => {
|
||||
switch (error.code) {
|
||||
case RecordTransformerExceptionCode.INVALID_PHONE_NUMBER:
|
||||
case RecordTransformerExceptionCode.INVALID_PHONE_COUNTRY_CODE:
|
||||
case RecordTransformerExceptionCode.CONFLICTING_PHONE_COUNTRY_CODE:
|
||||
case RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE:
|
||||
case RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE:
|
||||
case RecordTransformerExceptionCode.INVALID_PHONE_CALLING_CODE:
|
||||
case RecordTransformerExceptionCode.INVALID_URL:
|
||||
throw new UserInputError(error.message);
|
||||
default: {
|
||||
|
||||
@ -0,0 +1,217 @@
|
||||
import {
|
||||
isDefined,
|
||||
parseJson,
|
||||
removeUndefinedFields,
|
||||
} from 'twenty-shared/utils';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
CountryCallingCode,
|
||||
CountryCode,
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
parsePhoneNumberWithError,
|
||||
} from 'libphonenumber-js';
|
||||
|
||||
import {
|
||||
RecordTransformerException,
|
||||
RecordTransformerExceptionCode,
|
||||
} from 'src/engine/core-modules/record-transformer/record-transformer.exception';
|
||||
import {
|
||||
AdditionalPhoneMetadata,
|
||||
PhonesMetadata,
|
||||
} from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
|
||||
|
||||
const ALL_COUNTRIES_CODE = getCountries();
|
||||
|
||||
export type PhonesFieldGraphQLInput =
|
||||
| Partial<
|
||||
Omit<PhonesMetadata, 'additionalPhones'> & {
|
||||
additionalPhones: string | null;
|
||||
}
|
||||
>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type AdditionalPhoneMetadataWithNumber = Partial<AdditionalPhoneMetadata> &
|
||||
Required<Pick<AdditionalPhoneMetadata, 'number'>>;
|
||||
|
||||
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 = ({
|
||||
callingCode,
|
||||
countryCode,
|
||||
}: Partial<Omit<AdditionalPhoneMetadata, 'number'>>) => {
|
||||
if (isNonEmptyString(countryCode) && !isValidCountryCode(countryCode)) {
|
||||
throw new RecordTransformerException(
|
||||
`Invalid country code ${countryCode}`,
|
||||
RecordTransformerExceptionCode.INVALID_PHONE_COUNTRY_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(callingCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedCountryCodes = getCountryCodesForCallingCode(callingCode);
|
||||
|
||||
if (expectedCountryCodes.length === 0) {
|
||||
throw new RecordTransformerException(
|
||||
`Invalid calling code ${callingCode}`,
|
||||
RecordTransformerExceptionCode.INVALID_PHONE_CALLING_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isNonEmptyString(countryCode) &&
|
||||
expectedCountryCodes.every(
|
||||
(expectedCountryCode) => expectedCountryCode !== countryCode,
|
||||
)
|
||||
) {
|
||||
throw new RecordTransformerException(
|
||||
`Provided country code and calling code are conflicting`,
|
||||
RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const parsePhoneNumberExceptionWrapper = ({
|
||||
callingCode,
|
||||
countryCode,
|
||||
number,
|
||||
}: AdditionalPhoneMetadataWithNumber) => {
|
||||
try {
|
||||
return parsePhoneNumberWithError(number, {
|
||||
defaultCallingCode: callingCode
|
||||
? removePlusFromString(callingCode)
|
||||
: callingCode,
|
||||
defaultCountry: countryCode,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RecordTransformerException(
|
||||
`Provided phone number is invalid ${number}`,
|
||||
RecordTransformerExceptionCode.INVALID_PHONE_NUMBER,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndInferMetadataFromPrimaryPhoneNumber = ({
|
||||
callingCode,
|
||||
countryCode,
|
||||
number,
|
||||
}: AdditionalPhoneMetadataWithNumber): Partial<AdditionalPhoneMetadata> => {
|
||||
const phone = parsePhoneNumberExceptionWrapper({
|
||||
callingCode,
|
||||
countryCode,
|
||||
number,
|
||||
});
|
||||
|
||||
if (
|
||||
isNonEmptyString(phone.country) &&
|
||||
isNonEmptyString(countryCode) &&
|
||||
phone.country !== countryCode
|
||||
) {
|
||||
throw new RecordTransformerException(
|
||||
'Provided and inferred country code are conflicting',
|
||||
RecordTransformerExceptionCode.CONFLICTING_PHONE_COUNTRY_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isNonEmptyString(phone.countryCallingCode) &&
|
||||
isNonEmptyString(callingCode) &&
|
||||
phone.countryCallingCode !== removePlusFromString(callingCode)
|
||||
) {
|
||||
throw new RecordTransformerException(
|
||||
'Provided and inferred calling code are conflicting',
|
||||
RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
const finalPrimaryPhoneCallingCode =
|
||||
callingCode ??
|
||||
(`+${phone.countryCallingCode}` as undefined | CountryCallingCode);
|
||||
const finalPrimaryPhoneCountryCode = countryCode ?? phone.country;
|
||||
|
||||
return {
|
||||
countryCode: finalPrimaryPhoneCountryCode,
|
||||
callingCode: finalPrimaryPhoneCallingCode,
|
||||
number: phone.nationalNumber,
|
||||
};
|
||||
};
|
||||
|
||||
const validateAndInferPhoneInput = ({
|
||||
callingCode,
|
||||
countryCode,
|
||||
number,
|
||||
}: Partial<AdditionalPhoneMetadata>) => {
|
||||
validatePrimaryPhoneCountryCodeAndCallingCode({
|
||||
callingCode,
|
||||
countryCode,
|
||||
});
|
||||
|
||||
if (isDefined(number)) {
|
||||
return validateAndInferMetadataFromPrimaryPhoneNumber({
|
||||
number,
|
||||
callingCode,
|
||||
countryCode,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
callingCode,
|
||||
countryCode,
|
||||
number,
|
||||
};
|
||||
};
|
||||
|
||||
type TransformPhonesValueArgs = {
|
||||
input: PhonesFieldGraphQLInput;
|
||||
};
|
||||
export const transformPhonesValue = ({
|
||||
input,
|
||||
}: TransformPhonesValueArgs): PhonesFieldGraphQLInput => {
|
||||
if (!isDefined(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const { additionalPhones, ...primary } = input;
|
||||
const {
|
||||
callingCode: primaryPhoneCallingCode,
|
||||
countryCode: primaryPhoneCountryCode,
|
||||
number: primaryPhoneNumber,
|
||||
} = validateAndInferPhoneInput({
|
||||
callingCode: primary.primaryPhoneCallingCode,
|
||||
countryCode: primary.primaryPhoneCountryCode,
|
||||
number: primary.primaryPhoneNumber,
|
||||
});
|
||||
|
||||
const parsedAdditionalPhones = isDefined(additionalPhones)
|
||||
? parseJson<AdditionalPhoneMetadata[]>(additionalPhones)
|
||||
: additionalPhones;
|
||||
const transformedAdditionalPhones = isDefined(parsedAdditionalPhones)
|
||||
? JSON.stringify(parsedAdditionalPhones.map(validateAndInferPhoneInput))
|
||||
: parsedAdditionalPhones;
|
||||
|
||||
return removeUndefinedFields({
|
||||
additionalPhones: transformedAdditionalPhones,
|
||||
primaryPhoneCallingCode,
|
||||
primaryPhoneCountryCode,
|
||||
primaryPhoneNumber,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user