diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts index 26bd2d316..7569ea44b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { ObjectRecord, @@ -21,9 +22,9 @@ import { import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; -import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; type ArgPositionBackfillInput = { argIndex?: number; @@ -168,7 +169,7 @@ export class QueryRunnerArgsFactory { fieldMetadataMapByNameByName: Record, argPositionBackfillInput: ArgPositionBackfillInput, ): Promise> { - if (!data) { + if (!isDefined(data)) { return Promise.resolve({}); } @@ -210,6 +211,7 @@ export class QueryRunnerArgsFactory { } case FieldMetadataType.NUMBER: case FieldMetadataType.RICH_TEXT: + case FieldMetadataType.PHONES: case FieldMetadataType.RICH_TEXT_V2: case FieldMetadataType.LINKS: case FieldMetadataType.EMAILS: { diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/record-transformer.exception.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/record-transformer.exception.ts index 60bfbfe44..2ba94bf4a 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/record-transformer.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/record-transformer.exception.ts @@ -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', } diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts index b01f65163..97335dc03 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts @@ -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) { diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util.ts index 88fb37a2e..eb5d16859 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util.ts @@ -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: { diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-phones-value.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-phones-value.util.ts new file mode 100644 index 000000000..1329534d4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-phones-value.util.ts @@ -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 & { + additionalPhones: string | null; + } + > + | null + | undefined; + +type AdditionalPhoneMetadataWithNumber = Partial & + Required>; + +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>) => { + 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 => { + 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) => { + 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(additionalPhones) + : additionalPhones; + const transformedAdditionalPhones = isDefined(parsedAdditionalPhones) + ? JSON.stringify(parsedAdditionalPhones.map(validateAndInferPhoneInput)) + : parsedAdditionalPhones; + + return removeUndefinedFields({ + additionalPhones: transformedAdditionalPhones, + primaryPhoneCallingCode, + primaryPhoneCountryCode, + primaryPhoneNumber, + }); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts index 011fa4469..b9144098e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts @@ -1,4 +1,5 @@ import { FieldMetadataType } from 'twenty-shared/types'; +import { CountryCode } from 'libphonenumber-js'; import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface'; @@ -33,9 +34,18 @@ export const phonesCompositeType: CompositeType = { ], }; -export type PhonesMetadata = { - primaryPhoneNumber: string; - primaryPhoneCountryCode: string; - primaryPhoneCallingCode: string; - additionalPhones: object | null; +export type AdditionalPhoneMetadata = { + number: string; + countryCode: CountryCode; + callingCode: string; +}; + +type PrimaryPhoneMetadata< + T extends AdditionalPhoneMetadata = AdditionalPhoneMetadata, +> = { + [Property in keyof AdditionalPhoneMetadata as `primaryPhone${Capitalize}`]: T[Property]; +}; + +export type PhonesMetadata = PrimaryPhoneMetadata & { + additionalPhones: Array | null; }; diff --git a/packages/twenty-server/test/integration/graphql/utils/update-one-operation.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-one-operation.util.ts new file mode 100644 index 000000000..5f91e88ff --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-one-operation.util.ts @@ -0,0 +1,46 @@ +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { capitalize } from 'twenty-shared/utils'; +import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util'; + +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +type UpdateOneOperationArgs = PerformMetadataQueryParams & { + objectMetadataSingularName: string; + recordId: string; +}; +export const updateOneOperation = async ({ + input, + gqlFields = 'id', + objectMetadataSingularName, + expectToFail = false, + recordId, +}: UpdateOneOperationArgs): CommonResponseBody<{ + updateOneResponse: ObjectRecord; +}> => { + const graphqlOperation = updateOneOperationFactory({ + data: input as object, // TODO default generic does not work + objectMetadataSingularName, + gqlFields, + recordId, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + if (expectToFail) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Update one operation should have failed but did not', + }); + } + + return { + data: { + updateOneResponse: + response.body.data[`update${capitalize(objectMetadataSingularName)}`], + }, + errors: response.body.errors, + }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/phone/__snapshots__/create-one-field-metadata-phone.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/phone/__snapshots__/create-one-field-metadata-phone.integration-spec.ts.snap new file mode 100644 index 000000000..d295a9a76 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/phone/__snapshots__/create-one-field-metadata-phone.integration-spec.ts.snap @@ -0,0 +1,155 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Phone field metadata tests suite It should fail to create primary phone field with conflicting country code and calling code 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided country code and calling code are conflicting", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with conflicting country code and calling code 2`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided country code and calling code are conflicting", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with conflicting phone number calling code 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided and inferred calling code are conflicting", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with conflicting phone number calling code 2`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided and inferred calling code are conflicting", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with conflicting phone number country code 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided and inferred country code are conflicting", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with conflicting phone number country code 2`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided and inferred country code are conflicting", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with invalid calling code 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Invalid calling code +999", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with invalid calling code 2`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Invalid calling code +999", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with invalid country code 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Invalid country code XX", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with invalid country code 2`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Invalid country code XX", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with invalid phone number format 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided phone number is invalid not-a-number", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field with invalid phone number format 2`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided phone number is invalid not-a-number", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field without country or calling code at all 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided phone number is invalid 123456789", + }, +] +`; + +exports[`Phone field metadata tests suite It should fail to create primary phone field without country or calling code at all 2`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Provided phone number is invalid 123456789", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/phone/create-one-field-metadata-phone.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/phone/create-one-field-metadata-phone.integration-spec.ts new file mode 100644 index 000000000..ced900bf9 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/phone/create-one-field-metadata-phone.integration-spec.ts @@ -0,0 +1,417 @@ +import { faker } from '@faker-js/faker'; +import { CountryCode } from 'libphonenumber-js'; +import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { forceCreateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util'; +import { EachTestingContext } from 'twenty-shared/testing'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { + AdditionalPhoneMetadata, + PhonesMetadata, +} from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type'; + +const FIELD_NAME = 'phonenumber'; + +type TestCaseInputAndExpected = Partial< + Omit +> & { + additionalPhones?: Array> | null; +}; + +type CreatePhoneFieldMetadataTestCase = { + input: TestCaseInputAndExpected; + expected?: TestCaseInputAndExpected; +}; + +const SUCCESSFUL_TEST_CASES: EachTestingContext[] = + [ + { + title: 'create primary phone field', + context: { + input: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + additionalPhones: [], + }, + expected: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + additionalPhones: [], + }, + }, + }, + { + title: 'create primary phone field with number and other information', + context: { + input: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + additionalPhones: [], + }, + expected: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + additionalPhones: [], + }, + }, + }, + { + title: + 'create primary phone field with full international format and other information', + context: { + input: { + primaryPhoneNumber: '+1123456789', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + additionalPhones: null, + }, + expected: { + primaryPhoneNumber: '123456789', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + additionalPhones: null, + }, + }, + }, + { + title: + 'create primary phone field with full international and infer other information from it but not the countryCode as its shared', + context: { + input: { primaryPhoneNumber: '+1123456789' }, + expected: { + primaryPhoneNumber: '123456789', + primaryPhoneCountryCode: '' as CountryCode, + primaryPhoneCallingCode: '+1', + additionalPhones: null, + }, + }, + }, + { + title: + 'create primary phone field with full international and infer other information from it', + context: { + input: { primaryPhoneNumber: '+33123456789' }, + expected: { + primaryPhoneNumber: '123456789', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', + additionalPhones: null, + }, + }, + }, + { + title: 'create primary phone field with empty payload', + context: { + input: {}, + expected: { + primaryPhoneNumber: '', + primaryPhoneCountryCode: '' as CountryCode, + primaryPhoneCallingCode: '', + additionalPhones: null, + }, + }, + }, + { + title: 'create additional phone field with number and other information', + context: { + input: { + additionalPhones: [ + { callingCode: '+33', countryCode: 'FR', number: '123456789' }, + ], + }, + expected: { + primaryPhoneCallingCode: '', + primaryPhoneCountryCode: '' as CountryCode, + primaryPhoneNumber: '', + additionalPhones: [ + { callingCode: '+33', countryCode: 'FR', number: '123456789' }, + ], + }, + }, + }, + { + title: + 'create additional phone field with full international format and other information', + context: { + input: { + additionalPhones: [ + { callingCode: '+1', countryCode: 'US', number: '+1123456789' }, + ], + }, + expected: { + primaryPhoneCallingCode: '', + primaryPhoneCountryCode: '' as CountryCode, + primaryPhoneNumber: '', + additionalPhones: [ + { callingCode: '+1', countryCode: 'US', number: '123456789' }, + ], + }, + }, + }, + { + title: + 'create additional phone field with full international and infer other information from it but not the countryCode as its shared', + context: { + input: { additionalPhones: [{ number: '+1123456789' }] }, + expected: { + primaryPhoneCallingCode: '', + primaryPhoneCountryCode: '' as CountryCode, + primaryPhoneNumber: '', + additionalPhones: [ + { + callingCode: '+1', + number: '123456789', + }, + ], + }, + }, + }, + { + title: + 'create additional phone field with full international and infer other information from it', + context: { + input: { additionalPhones: [{ number: '+33123456789' }] }, + expected: { + primaryPhoneCallingCode: '', + primaryPhoneCountryCode: '' as CountryCode, + primaryPhoneNumber: '', + additionalPhones: [ + { callingCode: '+33', countryCode: 'FR', number: '123456789' }, + ], + }, + }, + }, + { + title: 'create additional phone field with empty payload', + context: { + input: { additionalPhones: [{}] }, + expected: { + primaryPhoneCallingCode: '', + primaryPhoneCountryCode: '' as CountryCode, + primaryPhoneNumber: '', + additionalPhones: [{}], + }, + }, + }, + ]; + +const FAILING_TEST_INPUTS: { input: Partial; label: string }[] = + [ + { + label: 'phone field without country or calling code at all', + input: { + primaryPhoneNumber: '123456789', + additionalPhones: [], + }, + }, + { + label: 'phone field with invalid country code', + input: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'XX' as CountryCode, + additionalPhones: [], + }, + }, + { + label: 'phone field with invalid calling code', + input: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+999', + primaryPhoneCountryCode: 'FR', + additionalPhones: [], + }, + }, + { + label: 'phone field with conflicting country code and calling code', + input: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'US', + additionalPhones: [], + }, + }, + { + label: 'phone field with invalid phone number format', + input: { + primaryPhoneNumber: 'not-a-number', + additionalPhones: [], + }, + }, + { + label: 'phone field with conflicting phone number country code', + input: { + primaryPhoneNumber: '+33123456789', + primaryPhoneCountryCode: 'US', + additionalPhones: [], + }, + }, + { + label: 'phone field with conflicting phone number calling code', + input: { + primaryPhoneNumber: '+33123456789', + primaryPhoneCallingCode: '+1', + additionalPhones: [], + }, + }, + ]; + +const primaryFailingTests = FAILING_TEST_INPUTS.map< + EachTestingContext +>(({ input, label }) => ({ + title: `create primary ${label}`, + context: { + input: { + ...input, + additionalPhones: [], + }, + }, +})); + +const additionalPhonesNumberFailingTests = FAILING_TEST_INPUTS.map< + EachTestingContext +>( + ({ + input: { + primaryPhoneCallingCode, + primaryPhoneCountryCode, + primaryPhoneNumber, + }, + label, + }) => ({ + title: `create primary ${label}`, + context: { + input: { + additionalPhones: [ + { + callingCode: primaryPhoneCallingCode, + countryCode: primaryPhoneCountryCode, + number: primaryPhoneNumber, + }, + ], + }, + }, + }), +); + +const FAILING_TEST_CASES: EachTestingContext[] = + [...primaryFailingTests, ...additionalPhonesNumberFailingTests]; + +describe('Phone field metadata tests suite', () => { + let createdObjectMetadataId: string; + + beforeEach(async () => { + const { data } = await forceCreateOneObjectMetadata({ + input: { + nameSingular: 'myTestObject', + namePlural: 'myTestObjects', + labelSingular: 'My Test Object', + labelPlural: 'My Test Objects', + icon: 'Icon123', + isLabelSyncedWithName: false, + }, + }); + + createdObjectMetadataId = data.createOneObject.id; + + await createOneFieldMetadata({ + input: { + name: FIELD_NAME, + label: 'Phone number', + type: FieldMetadataType.PHONES, + objectMetadataId: createdObjectMetadataId, + isLabelSyncedWithName: false, + }, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + }); + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + test.each(SUCCESSFUL_TEST_CASES)( + 'It should succeed $title', + + async ({ context: { input, expected } }) => { + const { + data: { createOneResponse }, + errors, + } = await createOneOperation<{ + id: string; + [FIELD_NAME]: any; + }>({ + objectMetadataSingularName: 'myTestObject', + input: { + id: faker.string.uuid(), + [FIELD_NAME]: input, + }, + gqlFields: ` + id + ${FIELD_NAME} { + primaryPhoneNumber + primaryPhoneCountryCode + primaryPhoneCallingCode + additionalPhones + __typename + } + `, + }); + + expect(errors).toBeUndefined(); + const { id: _id, ...rest } = createOneResponse; + + expect(rest[FIELD_NAME]).toStrictEqual({ + ...expected, + __typename: 'Phones', + }); + }, + ); + + test.each(FAILING_TEST_CASES)( + 'It should fail to $title', + + async ({ context: { input } }) => { + const { + data: { createOneResponse }, + errors, + } = await createOneOperation<{ + id: string; + [FIELD_NAME]: any; + }>({ + objectMetadataSingularName: 'myTestObject', + input: { + id: faker.string.uuid(), + [FIELD_NAME]: input, + }, + gqlFields: ` + id + ${FIELD_NAME} { + primaryPhoneNumber + primaryPhoneCountryCode + primaryPhoneCallingCode + additionalPhones + __typename + } + `, + }); + + expect(createOneResponse).toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/twenty-shared/src/utils/__tests__/removeUndefinedFields.test.ts b/packages/twenty-shared/src/utils/__tests__/removeUndefinedFields.test.ts new file mode 100644 index 000000000..25917b3e0 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/removeUndefinedFields.test.ts @@ -0,0 +1,134 @@ +import { removeUndefinedFields } from '../removeUndefinedFields'; + +interface PrimitiveTestContext { + description: string; + input: null | undefined | string | number | boolean; + expected: null | undefined | string | number | boolean; +} + +interface ObjectTestContext { + description: string; + input: Record; + expected: Record; +} + +describe('removeUndefinedFields', () => { + describe.each([ + { + description: 'null', + input: null, + expected: null, + }, + { + description: 'undefined', + input: undefined, + expected: undefined, + }, + { + description: 'string', + input: 'string', + expected: 'string', + }, + { + description: 'number', + input: 123, + expected: 123, + }, + { + description: 'boolean true', + input: true, + expected: true, + }, + { + description: 'boolean false', + input: false, + expected: false, + }, + ])('primitive value: $description', ({ input, expected }) => { + it('should return the value as is', () => { + expect(removeUndefinedFields(input)).toBe(expected); + }); + }); + + describe.each([ + { + description: 'flat object', + input: { + name: 'John', + age: 30, + email: undefined, + phone: null, + address: undefined, + }, + expected: { + name: 'John', + age: 30, + phone: null, + }, + }, + { + description: 'nested object', + input: { + name: 'John', + contact: { + email: undefined, + phone: '123456', + address: { + street: '123 Main St', + apt: undefined, + city: 'New York', + }, + }, + preferences: undefined, + }, + expected: { + name: 'John', + contact: { + phone: '123456', + address: { + street: '123 Main St', + city: 'New York', + }, + }, + }, + }, + { + description: 'arrays', + input: { + names: ['John', undefined, 'Jane', null], + tags: [ + { id: 1, label: 'active' }, + { id: undefined, label: 'pending' }, + undefined, + { id: 3, label: undefined }, + ], + }, + expected: { + names: ['John', 'Jane', null], + tags: [ + { id: 1, label: 'active' }, + { label: 'pending' }, + { id: 3 }, + ], + }, + }, + { + description: 'empty objects after cleaning', + input: { + name: 'John', + metadata: { + tags: undefined, + flags: undefined, + }, + settings: {}, + }, + expected: { + name: 'John', + }, + }, + ])('object case: $description', ({ input, expected }) => { + it('should clean undefined fields correctly', () => { + expect(removeUndefinedFields(input)).toEqual(expected); + }); + }); +}); \ No newline at end of file diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 593dff7b7..6a11da1bf 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -15,6 +15,7 @@ export { getLogoUrlFromDomainName, } from './image/getLogoUrlFromDomainName'; export { parseJson } from './parseJson'; +export { removeUndefinedFields } from './removeUndefinedFields'; export { capitalize } from './strings/capitalize'; export { absoluteUrlSchema } from './url/absoluteUrlSchema'; export { buildSignedPath } from './url/buildSignedPath'; diff --git a/packages/twenty-shared/src/utils/removeUndefinedFields.ts b/packages/twenty-shared/src/utils/removeUndefinedFields.ts new file mode 100644 index 000000000..72fc4b5c6 --- /dev/null +++ b/packages/twenty-shared/src/utils/removeUndefinedFields.ts @@ -0,0 +1,43 @@ +import { isUndefined } from '@sniptt/guards'; + +export const removeUndefinedFields = (input: T): T | Partial => { + if (input === undefined) { + return input; + } + + if (input === null || typeof input !== 'object') { + return input; + } + + if (Array.isArray(input)) { + return input + .map((item) => removeUndefinedFields(item)) + .filter((item) => !isUndefined(item)) as T; + } + + return Object.entries(input as Record).reduce( + (acc, [key, value]) => { + if (isUndefined(value)) { + return acc; + } + + if (value === null || value instanceof Date) { + return { ...acc, [key]: value }; + } + + if (typeof value === 'object') { + const cleaned = removeUndefinedFields(value); + if ( + !isUndefined(cleaned) && + Object.keys(cleaned as object).length > 0 + ) { + return { ...acc, [key]: cleaned }; + } + return acc; + } + + return { ...acc, [key]: value }; + }, + {} as Record, + ) as Partial; +};