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:
Paul Rastoin
2025-06-19 16:39:58 +02:00
committed by GitHub
parent 1d1718a8a8
commit e1393c4887
12 changed files with 1049 additions and 13 deletions

View File

@ -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<string, unknown>;
expected: Record<string, unknown>;
}
describe('removeUndefinedFields', () => {
describe.each<PrimitiveTestContext>([
{
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<ObjectTestContext>([
{
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);
});
});
});

View File

@ -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';

View File

@ -0,0 +1,43 @@
import { isUndefined } from '@sniptt/guards';
export const removeUndefinedFields = <T>(input: T): T | Partial<T> => {
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<string, unknown>).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<string, unknown>,
) as Partial<T>;
};