Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code e7f4a4cce8 chore: improve monitoring for fix: handle string-typed options in transformEnumV
**`resolver-validation.pipe.ts`**: Wrapped the `plainToInstance` call in a try-catch that converts any `@Transform` decorator errors into `UserInputError` GraphQL responses. Previously, if a `@Transform` function threw (e.g., `TypeError: options?.map is not a function`), the error propagated as an unhandled exception captured by Sentry. Now, these malformed-input errors are returned to the client as proper GraphQL `UserInputError` responses with a descriptive message, reducing Sentry noise from client-side input issues.

This follows the existing pattern in the same file where `safeClassValidatorValidateWrapper` already catches validation errors, and the pipe already throws `UserInputError` for invalid inputs.
2026-03-19 15:13:33 +00:00
Sonarly Claude Code eac6723d78 fix: handle string-typed options in transformEnumValue
https://sonarly.com/issue/16534?type=bug

`transformEnumValue()` calls `.map()` on the `options` value without checking if it's actually an array. When a client sends `options` as a JSON-encoded string (e.g., `"[{...}]"`) instead of a native JSON array via the `updateOneField` mutation, the `@Transform` decorator passes the raw string to `transformEnumValue()`, causing a `TypeError`.

Fix: **`transform-enum-value.ts`**: The function now accepts `string` in addition to `FieldMetadataDefaultOption[]`. When a string is received (from clients sending JSON-encoded options via GraphQL), it parses it with `JSON.parse`. It also guards against non-array parsed values by returning them as-is, letting downstream validation handle the error.

**`field-metadata.dto.ts`**: Removed the unsafe `as FieldMetadataDefaultOption[]` cast from the `@Transform` decorator, which was masking the type mismatch at compile time. The `transformEnumValue` function now handles the real runtime types directly. Removed the now-unused `FieldMetadataDefaultOption` import.

**`transform-enum-value.spec.ts`**: Added tests covering: undefined input, array input with digit-prefixed values, JSON string input, and JSON string input with digit-prefixed values.
2026-03-19 15:13:33 +00:00
19 changed files with 115 additions and 451 deletions
@@ -72,13 +72,11 @@ export const AgentChatFilePreview = ({
);
const rightComponent = onRemove ? (
<div onClick={(e) => e.stopPropagation()}>
<AvatarOrIcon
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
/>
</div>
<AvatarOrIcon
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
/>
) : undefined;
const hasRightDivider = isDefined(onRemove);
@@ -7,7 +7,6 @@ import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFi
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -42,8 +41,8 @@ export const RecordTitleCellSingleTextDisplayMode = ({
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const fieldValue = recordStore?.[fieldDefinition.metadata.fieldName];
const isEmpty = !isDefined(fieldValue) || fieldValue.trim() === '';
const isEmpty =
recordStore?.[fieldDefinition.metadata.fieldName]?.trim() === '';
const { openRecordTitleCell } = useRecordTitleCell();
@@ -519,94 +519,6 @@ describe('buildRecordFromImportedStructuredRow', () => {
});
});
it('should lowercase relation email composite subfield', () => {
const importedStructuredRow: ImportedStructuredRow = {
'emailField (relationField)': 'John.Doe@Example.COM',
};
const spreadsheetImportFields = [
{
fieldMetadataItemId: '6',
isNestedField: false,
isRelationConnectField: true,
label: 'Relation Field / Email Field',
key: 'emailField (relationField)',
fieldMetadataType: FieldMetadataType.RELATION,
uniqueFieldMetadataItem: {
name: 'emailField',
type: FieldMetadataType.EMAILS,
},
compositeSubFieldKey: 'primaryEmail',
},
] as SpreadsheetImportField[];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fieldMetadataItems: fields,
spreadsheetImportFields,
});
expect(result).toEqual({
relationField: {
connect: {
where: {
emailField: {
primaryEmail: 'john.doe@example.com',
},
},
},
},
createdBy: {
source: 'IMPORT',
context: {},
},
});
});
it('should normalize relation links composite subfield', () => {
const importedStructuredRow: ImportedStructuredRow = {
'domainNameField (relationField)': 'HTTPS://Example.COM/path/',
};
const spreadsheetImportFields = [
{
fieldMetadataItemId: '6',
isNestedField: false,
isRelationConnectField: true,
label: 'Relation Field / Domain Name Field',
key: 'domainNameField (relationField)',
fieldMetadataType: FieldMetadataType.RELATION,
uniqueFieldMetadataItem: {
name: 'linksField',
type: FieldMetadataType.LINKS,
},
compositeSubFieldKey: 'primaryLinkUrl',
},
] as SpreadsheetImportField[];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fieldMetadataItems: fields,
spreadsheetImportFields,
});
expect(result).toEqual({
relationField: {
connect: {
where: {
linksField: {
primaryLinkUrl: 'https://example.com/path',
},
},
},
},
createdBy: {
source: 'IMPORT',
context: {},
},
});
});
it('should return empty record for empty imported row', () => {
const importedStructuredRow: ImportedStructuredRow = {};
@@ -202,7 +202,7 @@ export const buildRecordFromImportedStructuredRow = ({
},
[FieldMetadataType.EMAILS]: {
primaryEmail: (value: unknown) => castToString(value).toLowerCase(),
primaryEmail: castToString,
additionalEmails: stringArrayJSONSchema.parse,
},
[FieldMetadataType.FULL_NAME]: {
@@ -1,6 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { FieldMetadataType } from 'twenty-shared/types';
import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service';
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
@@ -116,118 +116,6 @@ describe('DataArgProcessorService', () => {
expect(dataArgProcessorService).toBeDefined();
});
it('should normalize relation connect where composite values', async () => {
const flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
byUniversalIdentifier: {
'company-universal-id': {
id: 'company-id',
name: 'company',
type: FieldMetadataType.RELATION,
isNullable: true,
objectMetadataId: 'object-id',
universalIdentifier: 'company-universal-id',
relationTargetObjectMetadataId: 'target-company-object-id',
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'companyId',
},
} as FlatFieldMetadata,
'emails-universal-id': {
id: 'emails-id',
name: 'emails',
type: FieldMetadataType.EMAILS,
isNullable: true,
objectMetadataId: 'target-company-object-id',
universalIdentifier: 'emails-universal-id',
} as FlatFieldMetadata,
'domainName-universal-id': {
id: 'domainName-id',
name: 'domainName',
type: FieldMetadataType.LINKS,
isNullable: true,
objectMetadataId: 'target-company-object-id',
universalIdentifier: 'domainName-universal-id',
} as FlatFieldMetadata,
},
universalIdentifierById: {
'company-id': 'company-universal-id',
'emails-id': 'emails-universal-id',
'domainName-id': 'domainName-universal-id',
},
universalIdentifiersByApplicationId: {},
};
const flatObjectMetadata = {
id: 'object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
isCustom: false,
fieldIds: ['company-id'],
universalIdentifier: 'test-object-universal-id',
labelIdentifierFieldMetadataUniversalIdentifier: null,
imageIdentifierFieldMetadataUniversalIdentifier: null,
} as FlatObjectMetadata;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {
'target-company-universal-id': {
id: 'target-company-object-id',
nameSingular: 'company',
namePlural: 'companies',
isCustom: false,
fieldIds: ['emails-id', 'domainName-id'],
universalIdentifier: 'target-company-universal-id',
labelIdentifierFieldMetadataUniversalIdentifier: null,
imageIdentifierFieldMetadataUniversalIdentifier: null,
} as FlatObjectMetadata,
},
universalIdentifierById: {
'target-company-object-id': 'target-company-universal-id',
},
universalIdentifiersByApplicationId: {},
};
const result = await dataArgProcessorService.process({
partialRecordInputs: [
{
company: {
connect: {
where: {
emails: {
primaryEmail: 'User@Example.COM',
},
domainName: {
primaryLinkUrl: 'HTTPS://Example.COM/path/',
},
},
},
},
},
],
authContext: createMockAuthContext(),
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
expect(result).toEqual([
{
company: {
connect: {
where: {
emails: {
primaryEmail: 'user@example.com',
},
domainName: {
primaryLinkUrl: 'https://example.com/path',
},
},
},
},
},
]);
});
describe('failing inputs validation', () => {
const fieldMetadataTypesToTest = Object.keys(
failingInputsByFieldMetadataType,
@@ -258,11 +146,6 @@ describe('DataArgProcessorService', () => {
authContext: createMockAuthContext(),
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
},
}),
).rejects.toThrowErrorMatchingSnapshot();
});
@@ -300,11 +183,6 @@ describe('DataArgProcessorService', () => {
authContext: createMockAuthContext(),
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
},
});
expect(result).toBeDefined();
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isNull, isObject, isUndefined } from '@sniptt/guards';
import { isNull, isUndefined } from '@sniptt/guards';
import {
FieldMetadataSettingsMapping,
FieldMetadataType,
@@ -15,6 +15,7 @@ import {
} from 'twenty-shared/utils';
import { transformActorField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util';
import { isRelationNestedOperation } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util';
import { transformAddressField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-address-field.util';
import { transformArrayField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util';
import { transformCurrencyField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-currency-field.util';
@@ -22,7 +23,6 @@ import { transformFullNameField } from 'src/engine/api/common/common-args-proces
import { transformNumericField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-numeric-field.util';
import { transformRawJsonField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util';
import { transformTextField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util';
import { isRelationNestedOperation } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util';
import { validateActorFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-actor-field-or-throw.util';
import { validateAddressFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-address-field-or-throw.util';
import { validateArrayFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-array-field-or-throw.util';
@@ -56,12 +56,12 @@ import { transformLinksValue } from 'src/engine/core-modules/record-transformer/
import { transformPhonesValue } from 'src/engine/core-modules/record-transformer/utils/transform-phones-value.util';
import { transformRichTextValue } from 'src/engine/core-modules/record-transformer/utils/transform-rich-text.util';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@Injectable()
@@ -73,14 +73,12 @@ export class DataArgProcessorService {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined = true,
}: {
partialRecordInputs: Partial<ObjectRecord>[] | undefined;
authContext: WorkspaceAuthContext;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
shouldBackfillPositionIfUndefined?: boolean;
}): Promise<Partial<ObjectRecord>[]> {
if (!isDefined(partialRecordInputs)) {
@@ -160,8 +158,6 @@ export class DataArgProcessorService {
fieldMetadata,
key,
value,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
);
}
processedRecords.push(processedRecord);
@@ -174,8 +170,6 @@ export class DataArgProcessorService {
fieldMetadata: FlatFieldMetadata,
key: string,
value: unknown,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
): Promise<unknown> {
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION:
@@ -266,29 +260,6 @@ export class DataArgProcessorService {
);
}
const connectOperation = value as Record<
string,
Record<string, unknown>
>;
const connectWhere = connectOperation.connect?.where;
if (isObject(connectWhere)) {
const processedWhere = await this.processConnectWhere(
connectWhere as Record<string, unknown>,
fieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
);
return {
...connectOperation,
connect: {
...connectOperation.connect,
where: processedWhere,
},
};
}
return value;
}
case FieldMetadataType.PHONES: {
@@ -354,85 +325,4 @@ export class DataArgProcessorService {
);
}
}
private async processConnectWhere(
connectWhere: Record<string, unknown>,
relationFieldMetadata: FlatFieldMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
): Promise<Record<string, unknown>> {
if (!isDefined(relationFieldMetadata.relationTargetObjectMetadataId)) {
throw new CommonQueryRunnerException(
`Relation target object metadata id not found for field ${relationFieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const targetObjectMetadata =
findFlatEntityByIdInFlatEntityMaps<FlatObjectMetadata>({
flatEntityId: relationFieldMetadata.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(targetObjectMetadata)) {
throw new CommonQueryRunnerException(
`Relation target object metadata not found for field ${relationFieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
targetObjectMetadata,
);
const processedWhere: Record<string, unknown> = {};
for (const [whereKey, whereValue] of Object.entries(connectWhere)) {
const fieldId = fieldIdByName[whereKey];
if (!isDefined(fieldId)) {
processedWhere[whereKey] = whereValue;
continue;
}
const whereFieldMetadata =
findFlatEntityByIdInFlatEntityMaps<FlatFieldMetadata>({
flatEntityId: fieldId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(whereFieldMetadata)) {
processedWhere[whereKey] = whereValue;
continue;
}
try {
const processedValue = await this.processField(
whereFieldMetadata,
whereKey,
whereValue,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
);
// Only keep original keys — processField may add null subfields that alter WHERE semantics
if (isObject(whereValue) && isObject(processedValue)) {
const originalKeys = new Set(Object.keys(whereValue));
processedWhere[whereKey] = Object.fromEntries(
Object.entries(processedValue).filter(([k]) => originalKeys.has(k)),
);
} else {
processedWhere[whereKey] = processedValue;
}
} catch {
processedWhere[whereKey] = whereValue;
}
}
return processedWhere;
}
}
@@ -145,12 +145,8 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CommonInput<CreateManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<CreateManyQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
return {
...args,
@@ -159,7 +155,6 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}),
};
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { type ObjectRecord } from 'twenty-shared/types';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
@@ -14,7 +15,6 @@ import {
CreateOneQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -52,19 +52,14 @@ export class CommonCreateOneQueryRunnerService extends CommonBaseQueryRunnerServ
args: CommonInput<CreateOneQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<CreateOneQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const coercedData = await this.dataArgProcessor.process({
partialRecordInputs: [args.data],
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
@@ -176,12 +176,8 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
args: CommonInput<FindDuplicatesQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<FindDuplicatesQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
@@ -206,7 +202,6 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
}),
};
@@ -100,12 +100,8 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CommonInput<UpdateManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<UpdateManyQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
return {
...args,
@@ -120,7 +116,6 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
})
)[0],
@@ -65,12 +65,8 @@ export class CommonUpdateOneQueryRunnerService extends CommonBaseQueryRunnerServ
args: CommonInput<UpdateOneQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<UpdateOneQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
return {
...args,
@@ -80,7 +76,6 @@ export class CommonUpdateOneQueryRunnerService extends CommonBaseQueryRunnerServ
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
})
)[0],
@@ -1,5 +1,3 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -148,53 +146,4 @@ describe('objectRecordChangedValues', () => {
expect(result).toEqual(expectedChanges);
});
it('ignores changes to POSITION fields', () => {
const positionFieldId = 'position-field-id';
const positionUniversalId = 'position-universal-id';
const objectMetadataWithPosition: FlatObjectMetadata = {
...mockObjectMetadata,
fieldIds: [positionFieldId],
};
const flatFieldMetadataMapsWithPosition: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
[positionUniversalId]: {
id: positionFieldId,
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: positionUniversalId,
} as FlatFieldMetadata,
},
universalIdentifierById: {
[positionFieldId]: positionUniversalId,
},
universalIdentifiersByApplicationId: {},
};
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 1,
name: 'Original',
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 5,
name: 'Updated',
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
objectMetadataWithPosition,
flatFieldMetadataMapsWithPosition,
);
expect(result).toEqual({
name: { before: 'Original', after: 'Updated' },
});
expect(result).not.toHaveProperty('position');
});
});
@@ -56,8 +56,7 @@ export const objectRecordChangedValues = (
if (
key === 'updatedAt' ||
key === 'searchVector' ||
field?.type === FieldMetadataType.RELATION ||
field?.type === FieldMetadataType.POSITION
field?.type === FieldMetadataType.RELATION
) {
return acc;
}
@@ -29,7 +29,16 @@ export class ResolverValidationPipe implements PipeTransform {
return value;
}
const object = plainToInstance(metatype, value);
let object: object;
try {
object = plainToInstance(metatype, value);
} catch (error) {
throw new UserInputError(
`Invalid input: ${error instanceof Error ? error.message : 'transformation failed'}`,
);
}
const errors = await safeClassValidatorValidateWrapper(object);
if (errors.length === 0) {
@@ -128,11 +128,10 @@ export class CommonApiContextBuilderService {
authContext.apiKey.id,
workspaceId,
);
} else if (isApplicationAuthContext(authContext)) {
if (!isDefined(authContext.application.defaultRoleId)) {
return {};
}
} else if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
roleId = authContext.application.defaultRoleId;
} else if (isUserAuthContext(authContext)) {
const userWorkspaceRoleId =
@@ -33,7 +33,6 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { type FieldMetadataDefaultOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { transformEnumValue } from 'src/engine/utils/transform-enum-value';
@@ -132,9 +131,7 @@ export class FieldMetadataDTO<T extends FieldMetadataType = FieldMetadataType> {
@Field(() => GraphQLJSON, { nullable: true })
defaultValue?: FieldMetadataDefaultValue<T>;
@Transform(({ value }) =>
transformEnumValue(value as FieldMetadataDefaultOption[]),
)
@Transform(({ value }) => transformEnumValue(value))
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
options?: FieldMetadataOptions<T>;
@@ -15,7 +15,6 @@ import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import {
NavigationMenuItemException,
NavigationMenuItemExceptionCode,
@@ -337,25 +336,13 @@ export class NavigationMenuItemService {
existingUserWorkspaceId: flatNavigationMenuItemToDelete.userWorkspaceId,
});
const flatEntitiesToDelete = [flatNavigationMenuItemToDelete];
if (flatNavigationMenuItemToDelete.type === NavigationMenuItemType.FOLDER) {
const userWorkspaceIdKey =
flatNavigationMenuItemToDelete.userWorkspaceId ?? 'null';
const folderChildren =
existingFlatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[id] ?? [];
flatEntitiesToDelete.unshift(...folderChildren);
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: flatEntitiesToDelete,
flatEntityToDelete: [flatNavigationMenuItemToDelete],
flatEntityToUpdate: [],
},
},
@@ -0,0 +1,59 @@
import { transformEnumValue } from 'src/engine/utils/transform-enum-value';
describe('transformEnumValue', () => {
it('should return undefined when options is undefined', () => {
expect(transformEnumValue(undefined)).toBeUndefined();
});
it('should prefix option values starting with a digit', () => {
const options = [
{ value: '1_OPTION', label: 'Option 1', color: 'green' as const },
{ value: 'OPTION_2', label: 'Option 2', color: 'blue' as const },
];
const result = transformEnumValue(options);
expect(result).toEqual([
{ value: '_1_OPTION', label: 'Option 1', color: 'green' },
{ value: 'OPTION_2', label: 'Option 2', color: 'blue' },
]);
});
it('should parse and transform options passed as a JSON string', () => {
const options = JSON.stringify([
{ value: 'STARTUP', label: 'Startup', color: 'green' },
{ value: 'SME', label: 'SME', color: 'turquoise' },
]);
const result = transformEnumValue(options);
expect(result).toEqual([
{ value: 'STARTUP', label: 'Startup', color: 'green' },
{ value: 'SME', label: 'SME', color: 'turquoise' },
]);
});
it('should prefix digit-starting values in JSON string options', () => {
const options = JSON.stringify([
{ value: '3RD_PARTY', label: '3rd Party', color: 'red' },
]);
const result = transformEnumValue(options);
expect(result).toEqual([
{ value: '_3RD_PARTY', label: '3rd Party', color: 'red' },
]);
});
it('should return options unchanged when no values start with a digit', () => {
const options = [
{ value: 'STARTUP', label: 'Startup', color: 'green' as const },
];
const result = transformEnumValue(options);
expect(result).toEqual([
{ value: 'STARTUP', label: 'Startup', color: 'green' },
]);
});
});
@@ -1,7 +1,20 @@
import { type FieldMetadataDefaultOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
export function transformEnumValue(options?: FieldMetadataDefaultOption[]) {
return options?.map((option) => {
export function transformEnumValue(
options?: FieldMetadataDefaultOption[] | string,
) {
if (!options) {
return options;
}
const parsedOptions =
typeof options === 'string' ? JSON.parse(options) : options;
if (!Array.isArray(parsedOptions)) {
return parsedOptions;
}
return parsedOptions.map((option: FieldMetadataDefaultOption) => {
if (/^\d/.test(option.value)) {
return {
...option,