Fix user friendly message for non available object name (#14210)

Fixes https://github.com/twentyhq/twenty/issues/13577.

When naming or renaming an object, we are already checking that the name
was available compared to other existing objects.
But we also need to check that the relation fields that will be created
or updated are available as well: we create relation fields on
Attachment, Note, Favorite, Task and TimelineActivites, that bear the
name of the object. Thus, it is not possible to create an object that
has the same name as one of the fields on Attachment, Note etc: name,
createdAt, ... are not valid names.
This commit is contained in:
Marie
2025-09-01 16:35:40 +00:00
committed by GitHub
parent 3f7f78eef8
commit 0d5fedfb0a
13 changed files with 208 additions and 67 deletions
@@ -160,7 +160,7 @@ export class FieldMetadataRelationService {
) {
validateFieldNameAvailabilityOrThrow({
name: `${fieldMetadataInput.name}Id`,
objectMetadata,
fieldMetadataMapById: objectMetadata.fieldsById,
});
const relationCreationPayload = (
@@ -189,12 +189,12 @@ export class FieldMetadataRelationService {
validateFieldNameAvailabilityOrThrow({
name: computedMetadataNameFromLabel,
objectMetadata: objectMetadataTarget,
fieldMetadataMapById: objectMetadataTarget.fieldsById,
});
validateFieldNameAvailabilityOrThrow({
name: `${computedMetadataNameFromLabel}Id`,
objectMetadata: objectMetadataTarget,
fieldMetadataMapById: objectMetadataTarget.fieldsById,
});
if (
@@ -170,7 +170,7 @@ export class FieldMetadataValidationService {
try {
validateFieldNameAvailabilityOrThrow({
name: fieldMetadataInput.name,
objectMetadata,
fieldMetadataMapById: objectMetadata.fieldsById,
});
} catch (error) {
if (error instanceof InvalidMetadataException) {
@@ -10,4 +10,5 @@ export enum ObjectMetadataExceptionCode {
MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD = 'MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD',
INVALID_ORM_OUTPUT = 'INVALID_ORM_OUTPUT',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
NAME_CONFLICT = 'NAME_CONFLICT',
}
@@ -40,6 +40,7 @@ import {
} from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
import { SearchVectorService } from 'src/engine/metadata-modules/search-vector/search-vector.service';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { validateMetadataIdentifierFieldMetadataIds } from 'src/engine/metadata-modules/utils/validate-metadata-identifier-field-metadata-id.utils';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { validatesNoOtherObjectWithSameNameExistsOrThrows } from 'src/engine/metadata-modules/utils/validate-no-other-object-with-same-name-exists-or-throw.util';
@@ -221,10 +222,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
if (createObjectInput.isRemote) {
throw new Error('Remote objects are not supported yet');
} else {
const fieldsById = createdObjectMetadata.fields.reduce(
(acc, field) => ({
...acc,
[field.id]: field,
}),
{},
);
const createdRelatedObjectMetadataCollection =
await this.objectMetadataFieldRelationService.createRelationsAndForeignKeysMetadata(
createObjectInput.workspaceId,
createdObjectMetadata,
{ ...createdObjectMetadata, fieldsById },
objectMetadataMaps,
queryRunner,
);
@@ -374,12 +383,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
});
const { didUpdateLabelOrIcon } =
await this.handleObjectNameAndLabelUpdates(
await this.handleObjectNameAndLabelUpdates({
existingObjectMetadata,
existingObjectMetadataCombinedWithUpdateInput,
objectMetadataForUpdate:
existingObjectMetadataCombinedWithUpdateInput,
inputPayload,
queryRunner,
);
objectMetadataMaps,
});
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
@@ -602,11 +613,17 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
await this.objectMetadataRepository.delete({ workspaceId });
}
private async handleObjectNameAndLabelUpdates(
private async handleObjectNameAndLabelUpdates({
existingObjectMetadata,
objectMetadataForUpdate,
inputPayload,
queryRunner,
objectMetadataMaps,
}: {
existingObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>,
>;
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
@@ -617,10 +634,11 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
| 'labelPlural'
| 'icon'
| 'fieldsById'
>,
inputPayload: UpdateObjectPayload,
queryRunner: QueryRunner,
): Promise<{ didUpdateLabelOrIcon: boolean }> {
>;
inputPayload: UpdateObjectPayload;
queryRunner: QueryRunner;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<{ didUpdateLabelOrIcon: boolean }> {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
@@ -638,9 +656,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const relationMetadataCollection =
await this.objectMetadataFieldRelationService.updateRelationsAndForeignKeysMetadata(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
queryRunner,
{
workspaceId: objectMetadataForUpdate.workspaceId,
updatedObjectMetadata: objectMetadataForUpdate,
queryRunner,
objectMetadataMaps,
},
);
await this.objectMetadataMigrationService.updateRelationMigrations(
@@ -21,8 +21,11 @@ import {
import { buildDescriptionForRelationFieldMetadataOnFromField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-from-field.util';
import { buildDescriptionForRelationFieldMetadataOnToField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-to-field.util';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import {
CUSTOM_OBJECT_STANDARD_FIELD_IDS,
STANDARD_OBJECT_FIELD_IDS,
@@ -39,6 +42,11 @@ export const DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS = [
STANDARD_OBJECT_IDS.taskTarget,
];
type PartialRelationFieldMetadata = Partial<
FieldMetadataEntity<FieldMetadataType.RELATION>
> &
Required<Pick<FieldMetadataEntity<FieldMetadataType.RELATION>, 'name'>>;
@Injectable()
export class ObjectMetadataFieldRelationService {
constructor(
@@ -52,7 +60,7 @@ export class ObjectMetadataFieldRelationService {
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular'
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>,
objectMetadataMaps: ObjectMetadataMaps,
queryRunner?: QueryRunner,
@@ -83,8 +91,9 @@ export class ObjectMetadataFieldRelationService {
workspaceId: string;
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular'
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>;
objectMetadataMaps: ObjectMetadataMaps;
relationObjectMetadataStandardId: string;
queryRunner?: QueryRunner;
@@ -116,7 +125,7 @@ export class ObjectMetadataFieldRelationService {
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular'
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>,
targetObjectMetadata: ObjectMetadataItemWithFieldMaps,
queryRunner?: QueryRunner,
@@ -127,12 +136,22 @@ export class ObjectMetadataFieldRelationService {
targetObjectMetadata,
);
this.validateFieldNameAvailabilityOrThrow({
name: sourceFieldMetadata.name,
fieldMetadataMapById: sourceObjectMetadata.fieldsById,
});
const targetFieldMetadata = this.createTargetFieldMetadata(
workspaceId,
sourceObjectMetadata,
targetObjectMetadata,
);
this.validateFieldNameAvailabilityOrThrow({
name: targetFieldMetadata.name,
fieldMetadataMapById: sourceObjectMetadata.fieldsById,
});
const fieldMetadataRepository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
@@ -159,16 +178,22 @@ export class ObjectMetadataFieldRelationService {
]);
}
public async updateRelationsAndForeignKeysMetadata(
workspaceId: string,
public async updateRelationsAndForeignKeysMetadata({
workspaceId,
updatedObjectMetadata,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
updatedObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom' | 'id' | 'labelSingular'
>,
queryRunner?: QueryRunner,
): Promise<
>;
objectMetadataMaps: ObjectMetadataMaps;
queryRunner?: QueryRunner;
}): Promise<
{
targetObjectMetadata: ObjectMetadataEntity;
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}[]
@@ -176,39 +201,61 @@ export class ObjectMetadataFieldRelationService {
return await Promise.all(
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
async (relationObjectMetadataStandardId) =>
this.updateRelationAndForeignKeyMetadata(
this.updateRelationAndForeignKeyMetadata({
workspaceId,
updatedObjectMetadata,
relationObjectMetadataStandardId,
sourceObjectMetadata: updatedObjectMetadata,
targetObjectMetadataStandardId: relationObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
),
}),
),
);
}
private async updateRelationAndForeignKeyMetadata(
workspaceId: string,
private async updateRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata,
targetObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
sourceObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'id' | 'isCustom' | 'labelSingular'
>,
targetObjectMetadataStandardId: string,
queryRunner?: QueryRunner,
) {
const objectMetadataRepository = queryRunner
? queryRunner.manager.getRepository(ObjectMetadataEntity)
: this.objectMetadataRepository;
>;
targetObjectMetadataStandardId: string;
objectMetadataMaps: ObjectMetadataMaps;
queryRunner?: QueryRunner;
}): Promise<{
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}> {
const fieldMetadataRepository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
const targetObjectMetadata = await objectMetadataRepository.findOneByOrFail(
{
standardId: targetObjectMetadataStandardId,
workspaceId: workspaceId,
isCustom: false,
},
);
const targetObjectMetadataId = Object.values(objectMetadataMaps.byId).find(
(objectMetadata) =>
objectMetadata?.standardId === targetObjectMetadataStandardId &&
objectMetadata.isCustom === false,
)?.id;
if (!targetObjectMetadataId) {
throw new Error(
`Target object metadata id not found for standard ID: ${targetObjectMetadataStandardId}`,
);
}
const targetObjectMetadata =
objectMetadataMaps.byId[targetObjectMetadataId];
if (!targetObjectMetadata) {
throw new Error(
`Target object metadata not found for id: ${targetObjectMetadataId}`,
);
}
const targetFieldMetadataUpdateData = this.updateTargetFieldMetadata(
sourceObjectMetadata,
@@ -226,6 +273,25 @@ export class ObjectMetadataFieldRelationService {
workspaceId: workspaceId,
});
const nameIsUpdated =
targetFieldMetadataUpdateData.name !== targetFieldMetadataToUpdate.name;
if (nameIsUpdated) {
const targetObjectMetadataFieldsById =
objectMetadataMaps.byId[targetObjectMetadata.id]?.fieldsById;
if (!targetObjectMetadataFieldsById) {
throw new Error(
`Target object metadata fields not found for ${targetObjectMetadata.id}`,
);
}
this.validateFieldNameAvailabilityOrThrow({
name: targetFieldMetadataUpdateData.name,
fieldMetadataMapById: targetObjectMetadataFieldsById,
});
}
const isTargetFieldMetadataManyToOneRelation =
(
targetFieldMetadataToUpdate as FieldMetadataEntity<FieldMetadataType.RELATION>
@@ -293,7 +359,7 @@ export class ObjectMetadataFieldRelationService {
ObjectMetadataItemWithFieldMaps,
'namePlural' | 'labelSingular'
>,
): Partial<FieldMetadataEntity<FieldMetadataType.RELATION>> {
): PartialRelationFieldMetadata {
const relationObjectMetadataNamePlural = targetObjectMetadata.namePlural;
const { description } = buildDescriptionForRelationFieldMetadataOnFromField(
@@ -353,7 +419,7 @@ export class ObjectMetadataFieldRelationService {
ObjectMetadataItemWithFieldMaps,
'namePlural' | 'labelSingular' | 'id' | 'nameSingular'
>,
): Partial<FieldMetadataEntity<FieldMetadataType.RELATION>> {
): PartialRelationFieldMetadata {
const customStandardFieldId =
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom;
@@ -524,4 +590,30 @@ export class ObjectMetadataFieldRelationService {
return morphRelationFieldMetadataToUpdateWithNewJoinColumnName;
}
private validateFieldNameAvailabilityOrThrow({
name,
fieldMetadataMapById,
}: {
name: string;
fieldMetadataMapById: FieldMetadataMap;
}) {
try {
validateFieldNameAvailabilityOrThrow({
name,
fieldMetadataMapById,
});
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new ObjectMetadataException(
`Name "${name}" is not available.`,
ObjectMetadataExceptionCode.NAME_CONFLICT,
{
userFriendlyMessage: `Name "${name}" is not available.`,
},
);
}
throw error;
}
}
}
@@ -142,7 +142,7 @@ export class ObjectMetadataMigrationService {
currentObjectMetadata: Pick<ObjectMetadataEntity, 'nameSingular'>,
alteredObjectMetadata: Pick<ObjectMetadataEntity, 'nameSingular'>,
relationMetadataCollection: {
targetObjectMetadata: ObjectMetadataEntity;
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}[],
@@ -9,7 +9,7 @@ import {
export const buildDefaultFieldsForCustomObject = (
workspaceId: string,
): Partial<FieldMetadataEntity>[] => [
): Array<Partial<FieldMetadataEntity> & { id: string }> => [
{
id: v4(),
standardId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
@@ -31,6 +31,7 @@ export const objectMetadataGraphqlApiExceptionHandler = (error: Error) => {
case ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT:
throw new UserInputError(error);
case ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED:
case ObjectMetadataExceptionCode.NAME_CONFLICT:
throw new ForbiddenError(error);
case ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS:
throw new ConflictError(error);
@@ -64,14 +64,14 @@ describe('validateFieldNameAvailabilityOrThrow', () => {
expect(() =>
validateFieldNameAvailabilityOrThrow({
name: input,
objectMetadata: objectMetadataMapItemMock,
fieldMetadataMapById: objectMetadataMapItemMock.fieldsById,
}),
).not.toThrow();
} else {
expect(() =>
validateFieldNameAvailabilityOrThrow({
name: input,
objectMetadata: objectMetadataMapItemMock,
fieldMetadataMapById: objectMetadataMapItemMock.fieldsById,
}),
).toThrowErrorMatchingSnapshot();
}
@@ -4,18 +4,18 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
const getReservedCompositeFieldNames = (
objectMetadata: ObjectMetadataItemWithFieldMaps,
fieldMetadataMapById: FieldMetadataMap,
) => {
const reservedCompositeFieldsNames: string[] = [];
for (const field of Object.values(objectMetadata.fieldsById)) {
for (const field of Object.values(fieldMetadataMapById)) {
if (isCompositeFieldMetadataType(field.type)) {
const base = field.name;
const compositeType = compositeTypeDefinitions.get(field.type);
@@ -33,17 +33,17 @@ const getReservedCompositeFieldNames = (
type ValidateFieldNameAvailabilityOrThrowArgs = {
name: string;
objectMetadata: ObjectMetadataItemWithFieldMaps;
fieldMetadataMapById: FieldMetadataMap;
};
export const validateFieldNameAvailabilityOrThrow = ({
name,
objectMetadata,
fieldMetadataMapById,
}: ValidateFieldNameAvailabilityOrThrowArgs) => {
const reservedCompositeFieldsNames =
getReservedCompositeFieldNames(objectMetadata);
getReservedCompositeFieldNames(fieldMetadataMapById);
if (
Object.values(objectMetadata.fieldsById).some(
Object.values(fieldMetadataMapById).some(
(field) =>
field.name === name ||
(field.type === FieldMetadataType.RELATION &&
@@ -54,7 +54,7 @@ export const validateFieldNameAvailabilityOrThrow = ({
`Name "${name}" is not available as it is already used by another field`,
InvalidMetadataExceptionCode.NOT_AVAILABLE,
{
userFriendlyMessage: t`This name is not available as it is already used by another field`,
userFriendlyMessage: t`This name is not available as it is already used by another field.`,
},
);
}
@@ -6,7 +6,7 @@ exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "collisionfieldlabel" is not available as it is already used by another field",
"name": "UserInputError",
@@ -103,7 +103,7 @@ exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "collisionfieldlabel" is not available as it is already used by another field",
"name": "UserInputError",
@@ -6,7 +6,7 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE (rela
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "fieldNameBisId" is not available as it is already used by another field",
"name": "UserInputError",
@@ -20,7 +20,7 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE (rela
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "fieldName" is not available as it is already used by another field",
"name": "UserInputError",
@@ -145,7 +145,7 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY (rela
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "fieldNameBisId" is not available as it is already used by another field",
"name": "UserInputError",
@@ -159,7 +159,7 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY (rela
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "fieldName" is not available as it is already used by another field",
"name": "UserInputError",
@@ -188,7 +188,33 @@ describe('Custom object renaming', () => {
});
});
it('3. should delete custom object', async () => {
it('3. should fail when trying to rename object to "name"', async () => {
// Act & Assert
const { errors } = await updateOneObjectMetadata({
gqlFields: `
nameSingular
labelSingular
namePlural
labelPlural
`,
input: {
idToUpdate: listingObjectId,
updatePayload: {
nameSingular: 'name',
},
},
expectToFail: true,
});
// Assert that an error was thrown
expect(errors).toBeDefined();
expect(errors).toHaveLength(1);
expect(errors[0].extensions.userFriendlyMessage).toContain(
'Name "name" is not available',
);
});
it('4. should delete custom object', async () => {
const { data } = await deleteOneObjectMetadata({
input: {
idToDelete: listingObjectId,