fieldmetadatatype + featurelfag creation (#13021)
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -0,0 +1,138 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'class-validator';
|
||||
import omit from 'lodash.omit';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
|
||||
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
|
||||
import { 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 { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataMorphRelationService {
|
||||
constructor(
|
||||
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
|
||||
) {}
|
||||
|
||||
async createMorphRelationFieldMetadataItems({
|
||||
fieldMetadataForCreate,
|
||||
morphRelationsCreationPayload,
|
||||
objectMetadata,
|
||||
fieldMetadataRepository,
|
||||
objectMetadataMaps,
|
||||
}: {
|
||||
fieldMetadataForCreate: CreateFieldInput;
|
||||
morphRelationsCreationPayload: CreateFieldInput['morphRelationsCreationPayload'];
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps;
|
||||
fieldMetadataRepository: Repository<FieldMetadataEntity>;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
}): Promise<FieldMetadataEntity[]> {
|
||||
if (
|
||||
!isDefined(morphRelationsCreationPayload) ||
|
||||
!Array.isArray(morphRelationsCreationPayload)
|
||||
) {
|
||||
throw new FieldMetadataException(
|
||||
'Morph relations creation payload is not defined',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
|
||||
);
|
||||
}
|
||||
|
||||
if (morphRelationsCreationPayload.length < 1) {
|
||||
throw new FieldMetadataException(
|
||||
'Morph relations creation payload must not be empty',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
|
||||
);
|
||||
}
|
||||
|
||||
const fieldsCreated: FieldMetadataEntity[] = [];
|
||||
|
||||
for (const relation of morphRelationsCreationPayload) {
|
||||
const relationFieldMetadataForCreate =
|
||||
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
|
||||
{
|
||||
fieldMetadataInput: fieldMetadataForCreate,
|
||||
relationCreationPayload: relation,
|
||||
objectMetadata,
|
||||
},
|
||||
);
|
||||
|
||||
await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics(
|
||||
{
|
||||
fieldMetadataInput: relationFieldMetadataForCreate,
|
||||
fieldMetadataType: relationFieldMetadataForCreate.type,
|
||||
objectMetadataMaps,
|
||||
},
|
||||
);
|
||||
|
||||
const createdFieldMetadataItem = await fieldMetadataRepository.save(
|
||||
omit(relationFieldMetadataForCreate, 'id'),
|
||||
);
|
||||
|
||||
const targetFieldMetadataName = computeMetadataNameFromLabel(
|
||||
relation.targetFieldLabel,
|
||||
);
|
||||
|
||||
const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation(
|
||||
{
|
||||
objectMetadataId: relation.targetObjectMetadataId,
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: targetFieldMetadataName,
|
||||
label: relation.targetFieldLabel,
|
||||
icon: relation.targetFieldIcon,
|
||||
workspaceId: fieldMetadataForCreate.workspaceId,
|
||||
settings: fieldMetadataForCreate.settings,
|
||||
},
|
||||
);
|
||||
|
||||
const targetFieldMetadataToCreateWithRelation =
|
||||
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
|
||||
{
|
||||
fieldMetadataInput: targetFieldMetadataToCreate,
|
||||
relationCreationPayload: {
|
||||
targetObjectMetadataId: objectMetadata.id,
|
||||
targetFieldLabel: fieldMetadataForCreate.label,
|
||||
targetFieldIcon: fieldMetadataForCreate.icon ?? 'Icon123',
|
||||
type:
|
||||
relation.type === RelationType.ONE_TO_MANY
|
||||
? RelationType.MANY_TO_ONE
|
||||
: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
objectMetadata,
|
||||
},
|
||||
);
|
||||
|
||||
// todo better type
|
||||
const targetFieldMetadataToCreateWithRelationWithId = {
|
||||
id: v4(),
|
||||
...targetFieldMetadataToCreateWithRelation,
|
||||
};
|
||||
|
||||
const targetFieldMetadata = await fieldMetadataRepository.save({
|
||||
...targetFieldMetadataToCreateWithRelationWithId,
|
||||
relationTargetFieldMetadataId: createdFieldMetadataItem.id,
|
||||
});
|
||||
|
||||
const createdFieldMetadataItemUpdated =
|
||||
await fieldMetadataRepository.save({
|
||||
...createdFieldMetadataItem,
|
||||
relationTargetFieldMetadataId: targetFieldMetadata.id,
|
||||
});
|
||||
|
||||
fieldsCreated.push(createdFieldMetadataItemUpdated, targetFieldMetadata);
|
||||
}
|
||||
|
||||
return fieldsCreated;
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants';
|
||||
import { isDefined, parseJson } from 'twenty-shared/utils';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
import { settings } from 'src/engine/constants/settings';
|
||||
import {
|
||||
FieldMetadataComplexOption,
|
||||
FieldMetadataDefaultOption,
|
||||
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
|
||||
import { SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
|
||||
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
|
||||
@ -310,4 +314,74 @@ export class FieldMetadataRelatedRecordsService {
|
||||
private getMaxPosition(viewGroups: ViewGroupWorkspaceEntity[]): number {
|
||||
return viewGroups.reduce((max, group) => Math.max(max, group.position), 0);
|
||||
}
|
||||
|
||||
async createViewAndViewFields(
|
||||
createdFieldMetadatas: FieldMetadataEntity[],
|
||||
workspaceId: string,
|
||||
) {
|
||||
const workspaceDataSource =
|
||||
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await workspaceDataSource.transaction(
|
||||
async (workspaceEntityManager: WorkspaceEntityManager) => {
|
||||
const viewsRepository = workspaceEntityManager.getRepository('view', {
|
||||
shouldBypassPermissionChecks: true,
|
||||
});
|
||||
|
||||
const viewFieldsRepository = workspaceEntityManager.getRepository(
|
||||
'viewField',
|
||||
{
|
||||
shouldBypassPermissionChecks: true,
|
||||
},
|
||||
);
|
||||
|
||||
for (const createdFieldMetadata of createdFieldMetadatas) {
|
||||
const views = await viewsRepository.find({
|
||||
where: {
|
||||
objectMetadataId: createdFieldMetadata.objectMetadataId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isEmpty(views)) {
|
||||
const view = views[0];
|
||||
const existingViewFields = await viewFieldsRepository.find({
|
||||
where: {
|
||||
viewId: view.id,
|
||||
},
|
||||
});
|
||||
|
||||
const isVisible =
|
||||
existingViewFields.length < settings.maxVisibleViewFields;
|
||||
|
||||
const createdFieldIsAlreadyInView = existingViewFields.some(
|
||||
(existingViewField) =>
|
||||
existingViewField.fieldMetadataId === createdFieldMetadata.id,
|
||||
);
|
||||
|
||||
if (!createdFieldIsAlreadyInView) {
|
||||
const lastPosition = existingViewFields
|
||||
.map((viewField) => viewField.position)
|
||||
.reduce((acc, position) => {
|
||||
if (position > acc) {
|
||||
return position;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, -1);
|
||||
|
||||
await viewFieldsRepository.insert({
|
||||
fieldMetadataId: createdFieldMetadata.id,
|
||||
position: lastPosition + 1,
|
||||
isVisible,
|
||||
size: 180,
|
||||
viewId: view.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,330 @@
|
||||
import { Injectable, ValidationError } from '@nestjs/common';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
|
||||
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
|
||||
import { 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 { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps';
|
||||
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
|
||||
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
|
||||
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
|
||||
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
export class RelationCreationPayloadValidation {
|
||||
@IsUUID()
|
||||
targetObjectMetadataId?: string;
|
||||
|
||||
@IsString()
|
||||
targetFieldLabel: string;
|
||||
|
||||
@IsString()
|
||||
targetFieldIcon: string;
|
||||
|
||||
@IsEnum(RelationType)
|
||||
type: RelationType;
|
||||
}
|
||||
|
||||
type ValidateFieldMetadataArgs<T extends UpdateFieldInput | CreateFieldInput> =
|
||||
{
|
||||
fieldMetadataType: FieldMetadataType;
|
||||
fieldMetadataInput: T;
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps;
|
||||
existingFieldMetadata?: FieldMetadataInterface;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataRelationService {
|
||||
constructor(
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
) {}
|
||||
|
||||
async createRelationFieldMetadataItems({
|
||||
fieldMetadataInput,
|
||||
objectMetadata,
|
||||
fieldMetadataRepository,
|
||||
}: {
|
||||
fieldMetadataInput: CreateFieldInput;
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps;
|
||||
fieldMetadataRepository: Repository<FieldMetadataEntity>;
|
||||
}): Promise<FieldMetadataEntity[]> {
|
||||
const createdFieldMetadataItem =
|
||||
await fieldMetadataRepository.save(fieldMetadataInput);
|
||||
|
||||
const relationCreationPayload = fieldMetadataInput.relationCreationPayload;
|
||||
|
||||
if (!isDefined(relationCreationPayload)) {
|
||||
throw new FieldMetadataException(
|
||||
'Relation creation payload is not defined',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
|
||||
);
|
||||
}
|
||||
const targetFieldMetadataName = computeMetadataNameFromLabel(
|
||||
relationCreationPayload.targetFieldLabel,
|
||||
);
|
||||
|
||||
const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation({
|
||||
objectMetadataId: relationCreationPayload.targetObjectMetadataId,
|
||||
type: fieldMetadataInput.type,
|
||||
name: targetFieldMetadataName,
|
||||
label: relationCreationPayload.targetFieldLabel,
|
||||
icon: relationCreationPayload.targetFieldIcon,
|
||||
workspaceId: fieldMetadataInput.workspaceId,
|
||||
});
|
||||
|
||||
const targetFieldMetadataToCreateWithRelation =
|
||||
await this.addCustomRelationFieldMetadataForCreation({
|
||||
fieldMetadataInput: targetFieldMetadataToCreate,
|
||||
relationCreationPayload: {
|
||||
targetObjectMetadataId: objectMetadata.id,
|
||||
targetFieldLabel: fieldMetadataInput.label,
|
||||
targetFieldIcon: fieldMetadataInput.icon ?? 'Icon123',
|
||||
type:
|
||||
relationCreationPayload.type === RelationType.ONE_TO_MANY
|
||||
? RelationType.MANY_TO_ONE
|
||||
: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
objectMetadata,
|
||||
});
|
||||
|
||||
// todo better type
|
||||
const targetFieldMetadataToCreateWithRelationWithId = {
|
||||
id: v4(),
|
||||
...targetFieldMetadataToCreateWithRelation,
|
||||
};
|
||||
|
||||
const targetFieldMetadata = await fieldMetadataRepository.save({
|
||||
...targetFieldMetadataToCreateWithRelationWithId,
|
||||
relationTargetFieldMetadataId: createdFieldMetadataItem.id,
|
||||
});
|
||||
|
||||
const createdFieldMetadataItemUpdated = await fieldMetadataRepository.save({
|
||||
...createdFieldMetadataItem,
|
||||
relationTargetFieldMetadataId: targetFieldMetadata.id,
|
||||
});
|
||||
|
||||
return [createdFieldMetadataItemUpdated, targetFieldMetadata];
|
||||
}
|
||||
|
||||
async validateFieldMetadataRelationSpecifics<
|
||||
T extends UpdateFieldInput | CreateFieldInput,
|
||||
>({
|
||||
fieldMetadataInput,
|
||||
fieldMetadataType,
|
||||
objectMetadataMaps,
|
||||
}: Pick<
|
||||
ValidateFieldMetadataArgs<T>,
|
||||
'fieldMetadataInput' | 'fieldMetadataType' | 'objectMetadataMaps'
|
||||
>): Promise<T> {
|
||||
// TODO: clean typings, we should try to validate both update and create inputs in the same function
|
||||
const isRelation =
|
||||
fieldMetadataType === FieldMetadataType.RELATION ||
|
||||
fieldMetadataType === FieldMetadataType.MORPH_RELATION;
|
||||
|
||||
if (
|
||||
isRelation &&
|
||||
isDefined(
|
||||
(fieldMetadataInput as unknown as CreateFieldInput)
|
||||
.relationCreationPayload,
|
||||
)
|
||||
) {
|
||||
const relationCreationPayload = (
|
||||
fieldMetadataInput as unknown as CreateFieldInput
|
||||
).relationCreationPayload;
|
||||
|
||||
if (isDefined(relationCreationPayload)) {
|
||||
await this.validateRelationCreationPayloadOrThrow(
|
||||
relationCreationPayload,
|
||||
);
|
||||
const computedMetadataNameFromLabel = computeMetadataNameFromLabel(
|
||||
relationCreationPayload.targetFieldLabel,
|
||||
);
|
||||
|
||||
validateMetadataNameOrThrow(computedMetadataNameFromLabel);
|
||||
|
||||
const objectMetadataTarget =
|
||||
objectMetadataMaps.byId[
|
||||
relationCreationPayload.targetObjectMetadataId
|
||||
];
|
||||
|
||||
validateFieldNameAvailabilityOrThrow(
|
||||
computedMetadataNameFromLabel,
|
||||
objectMetadataTarget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return fieldMetadataInput;
|
||||
}
|
||||
|
||||
private async validateRelationCreationPayloadOrThrow(
|
||||
relationCreationPayload: RelationCreationPayloadValidation,
|
||||
) {
|
||||
try {
|
||||
const relationCreationPayloadInstance = plainToInstance(
|
||||
RelationCreationPayloadValidation,
|
||||
relationCreationPayload,
|
||||
);
|
||||
|
||||
await validateOrReject(relationCreationPayloadInstance);
|
||||
} catch (error) {
|
||||
const errorMessages = Array.isArray(error)
|
||||
? error
|
||||
.map((err: ValidationError) => Object.values(err.constraints ?? {}))
|
||||
.flat()
|
||||
.join(', ')
|
||||
: error.message;
|
||||
|
||||
throw new FieldMetadataException(
|
||||
`Relation creation payload is invalid: ${errorMessages}`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async findCachedFieldMetadataRelation(
|
||||
fieldMetadataItems: Array<
|
||||
Pick<
|
||||
FieldMetadataInterface,
|
||||
| 'id'
|
||||
| 'type'
|
||||
| 'objectMetadataId'
|
||||
| 'relationTargetFieldMetadataId'
|
||||
| 'relationTargetObjectMetadataId'
|
||||
>
|
||||
>,
|
||||
workspaceId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
sourceObjectMetadata: ObjectMetadataEntity;
|
||||
sourceFieldMetadata: FieldMetadataEntity;
|
||||
targetObjectMetadata: ObjectMetadataEntity;
|
||||
targetFieldMetadata: FieldMetadataEntity;
|
||||
}>
|
||||
> {
|
||||
const objectMetadataMaps =
|
||||
await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return fieldMetadataItems.map((fieldMetadataItem) => {
|
||||
const {
|
||||
id,
|
||||
objectMetadataId,
|
||||
relationTargetFieldMetadataId,
|
||||
relationTargetObjectMetadataId,
|
||||
} = fieldMetadataItem;
|
||||
|
||||
if (!relationTargetObjectMetadataId || !relationTargetFieldMetadataId) {
|
||||
throw new FieldMetadataException(
|
||||
`Relation target object metadata id or relation target field metadata id not found for field metadata ${id}`,
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceObjectMetadata = objectMetadataMaps.byId[objectMetadataId];
|
||||
const targetObjectMetadata =
|
||||
objectMetadataMaps.byId[relationTargetObjectMetadataId];
|
||||
const sourceFieldMetadata = sourceObjectMetadata?.fieldsById[id];
|
||||
const targetFieldMetadata =
|
||||
targetObjectMetadata?.fieldsById[relationTargetFieldMetadataId];
|
||||
|
||||
if (
|
||||
!sourceObjectMetadata ||
|
||||
!targetObjectMetadata ||
|
||||
!sourceFieldMetadata ||
|
||||
!targetFieldMetadata
|
||||
) {
|
||||
throw new FieldMetadataException(
|
||||
`Field relation metadata not found for field metadata ${id}`,
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sourceObjectMetadata:
|
||||
getObjectMetadataFromObjectMetadataItemWithFieldMaps(
|
||||
sourceObjectMetadata,
|
||||
) as ObjectMetadataEntity,
|
||||
sourceFieldMetadata: sourceFieldMetadata as FieldMetadataEntity,
|
||||
targetObjectMetadata:
|
||||
getObjectMetadataFromObjectMetadataItemWithFieldMaps(
|
||||
targetObjectMetadata,
|
||||
) as ObjectMetadataEntity,
|
||||
targetFieldMetadata: targetFieldMetadata as FieldMetadataEntity,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
addCustomRelationFieldMetadataForCreation({
|
||||
fieldMetadataInput,
|
||||
relationCreationPayload,
|
||||
objectMetadata,
|
||||
}: {
|
||||
fieldMetadataInput: CreateFieldInput;
|
||||
relationCreationPayload: CreateFieldInput['relationCreationPayload'];
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps;
|
||||
}) {
|
||||
const isRelation =
|
||||
isFieldMetadataInterfaceOfType(
|
||||
fieldMetadataInput,
|
||||
FieldMetadataType.RELATION,
|
||||
) ||
|
||||
isFieldMetadataInterfaceOfType(
|
||||
fieldMetadataInput,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
);
|
||||
|
||||
const isManyToOne =
|
||||
isRelation && relationCreationPayload?.type === RelationType.MANY_TO_ONE;
|
||||
|
||||
const isOneToMany =
|
||||
isRelation && relationCreationPayload?.type === RelationType.ONE_TO_MANY;
|
||||
|
||||
const defaultIcon = 'IconRelationOneToMany';
|
||||
|
||||
const joinColumnName = `${fieldMetadataInput.name}${capitalize(objectMetadata.nameSingular)}Id`;
|
||||
|
||||
return {
|
||||
...fieldMetadataInput,
|
||||
icon: fieldMetadataInput.icon ?? defaultIcon,
|
||||
relationCreationPayload,
|
||||
relationTargetObjectMetadataId:
|
||||
relationCreationPayload?.targetObjectMetadataId,
|
||||
settings: {
|
||||
...fieldMetadataInput.settings,
|
||||
...(isOneToMany
|
||||
? {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
}
|
||||
: {}),
|
||||
...(isManyToOne
|
||||
? {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
joinColumnName,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { ClassConstructor, plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Max,
|
||||
Min,
|
||||
ValidationError,
|
||||
isDefined,
|
||||
validateOrReject,
|
||||
} from 'class-validator';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
|
||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||
|
||||
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
|
||||
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
|
||||
import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
|
||||
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-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 { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
|
||||
|
||||
type ValidateFieldMetadataArgs = {
|
||||
fieldMetadataType: FieldMetadataType;
|
||||
fieldMetadataInput: CreateFieldInput | UpdateFieldInput;
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps;
|
||||
existingFieldMetadata?: FieldMetadataInterface;
|
||||
};
|
||||
|
||||
enum ValueType {
|
||||
PERCENTAGE = 'percentage',
|
||||
NUMBER = 'number',
|
||||
SHORT_NUMBER = 'shortNumber',
|
||||
}
|
||||
|
||||
class NumberSettingsValidation {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
decimals?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ValueType)
|
||||
type?: 'percentage' | 'number' | 'shortNumber';
|
||||
}
|
||||
|
||||
class TextSettingsValidation {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
displayedMaxRows?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataValidationService {
|
||||
constructor(
|
||||
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
|
||||
) {}
|
||||
|
||||
async validateSettingsOrThrow<T extends FieldMetadataType>({
|
||||
fieldType,
|
||||
settings,
|
||||
}: {
|
||||
fieldType: FieldMetadataType;
|
||||
settings: FieldMetadataSettings<T>;
|
||||
}) {
|
||||
switch (fieldType) {
|
||||
case FieldMetadataType.NUMBER:
|
||||
await this.validateSettings<FieldMetadataType.NUMBER>(
|
||||
NumberSettingsValidation,
|
||||
settings,
|
||||
);
|
||||
break;
|
||||
case FieldMetadataType.TEXT:
|
||||
await this.validateSettings<FieldMetadataType.TEXT>(
|
||||
TextSettingsValidation,
|
||||
settings,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSettings<Type extends FieldMetadataType>(
|
||||
validator: ClassConstructor<
|
||||
Type extends FieldMetadataType.NUMBER
|
||||
? NumberSettingsValidation
|
||||
: Type extends FieldMetadataType.TEXT
|
||||
? TextSettingsValidation
|
||||
: never
|
||||
>,
|
||||
settings: FieldMetadataSettings<Type>,
|
||||
) {
|
||||
try {
|
||||
const settingsInstance = plainToInstance(validator, settings);
|
||||
|
||||
await validateOrReject(settingsInstance);
|
||||
} catch (error) {
|
||||
const errorMessages = Array.isArray(error)
|
||||
? error
|
||||
.map((err: ValidationError) => Object.values(err.constraints ?? {}))
|
||||
.flat()
|
||||
.join(', ')
|
||||
: error.message;
|
||||
|
||||
throw new FieldMetadataException(
|
||||
`Value for settings is invalid: ${errorMessages}`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateFieldMetadata({
|
||||
fieldMetadataInput,
|
||||
fieldMetadataType,
|
||||
objectMetadata,
|
||||
existingFieldMetadata,
|
||||
}: ValidateFieldMetadataArgs): Promise<void> {
|
||||
if (fieldMetadataInput.name) {
|
||||
try {
|
||||
validateMetadataNameOrThrow(fieldMetadataInput.name);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidMetadataException) {
|
||||
throw new FieldMetadataException(
|
||||
error.message,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
validateFieldNameAvailabilityOrThrow(
|
||||
fieldMetadataInput.name,
|
||||
objectMetadata,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidMetadataException) {
|
||||
throw new FieldMetadataException(
|
||||
`Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
{
|
||||
userFriendlyMessage: t`Name is not available, it may be duplicating another field's name.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldMetadataInput.isNullable === false) {
|
||||
if (!isDefined(fieldMetadataInput.defaultValue)) {
|
||||
throw new FieldMetadataException(
|
||||
'Default value is required for non nullable fields',
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnumFieldMetadataType(fieldMetadataType)) {
|
||||
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
|
||||
{
|
||||
fieldMetadataInput,
|
||||
fieldMetadataType,
|
||||
existingFieldMetadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMetadataInput.settings) {
|
||||
await this.validateSettingsOrThrow({
|
||||
fieldType: fieldMetadataType,
|
||||
settings: fieldMetadataInput.settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,743 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource, FindOneOptions, In, Repository } from 'typeorm';
|
||||
|
||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
|
||||
import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
|
||||
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service';
|
||||
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
|
||||
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
|
||||
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service';
|
||||
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
|
||||
import { buildUpdatableStandardFieldInput } from 'src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util';
|
||||
import { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw';
|
||||
import {
|
||||
computeColumnName,
|
||||
computeCompositeColumnName,
|
||||
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
||||
import { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
|
||||
import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util';
|
||||
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
|
||||
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
|
||||
import { 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 { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
|
||||
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
||||
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
|
||||
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
|
||||
import {
|
||||
WorkspaceMigrationColumnActionType,
|
||||
WorkspaceMigrationColumnDrop,
|
||||
WorkspaceMigrationTableAction,
|
||||
WorkspaceMigrationTableActionType,
|
||||
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
|
||||
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
||||
import { ViewService } from 'src/modules/view/services/view.service';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
|
||||
constructor(
|
||||
@InjectDataSource('core')
|
||||
private readonly coreDataSource: DataSource,
|
||||
@InjectRepository(FieldMetadataEntity, 'core')
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
|
||||
private readonly workspaceMigrationService: WorkspaceMigrationService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
|
||||
private readonly viewService: ViewService,
|
||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
||||
private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService,
|
||||
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
|
||||
) {
|
||||
super(fieldMetadataRepository);
|
||||
}
|
||||
|
||||
override async createOne(
|
||||
fieldMetadataInput: CreateFieldInput,
|
||||
): Promise<FieldMetadataEntity> {
|
||||
const [createdFieldMetadata] = await this.createMany([fieldMetadataInput]);
|
||||
|
||||
if (!isDefined(createdFieldMetadata)) {
|
||||
throw new FieldMetadataException(
|
||||
'Failed to create field metadata',
|
||||
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return createdFieldMetadata;
|
||||
}
|
||||
|
||||
override async updateOne(
|
||||
id: string,
|
||||
fieldMetadataInput: UpdateFieldInput,
|
||||
): Promise<FieldMetadataEntity> {
|
||||
const { objectMetadataMaps } =
|
||||
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
|
||||
{ workspaceId: fieldMetadataInput.workspaceId },
|
||||
);
|
||||
|
||||
let existingFieldMetadata: FieldMetadataInterface | undefined;
|
||||
|
||||
for (const objectMetadataItem of Object.values(objectMetadataMaps.byId)) {
|
||||
const fieldMetadata = objectMetadataItem.fieldsById[id];
|
||||
|
||||
if (fieldMetadata) {
|
||||
existingFieldMetadata = fieldMetadata;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDefined(existingFieldMetadata)) {
|
||||
throw new FieldMetadataException(
|
||||
'Field does not exist',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const objectMetadataItemWithFieldMaps =
|
||||
objectMetadataMaps.byId[existingFieldMetadata.objectMetadataId];
|
||||
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const fieldMetadataRepository =
|
||||
queryRunner.manager.getRepository<FieldMetadataEntity>(
|
||||
FieldMetadataEntity,
|
||||
);
|
||||
|
||||
if (
|
||||
!isDefined(
|
||||
objectMetadataItemWithFieldMaps.labelIdentifierFieldMetadataId,
|
||||
)
|
||||
) {
|
||||
throw new FieldMetadataException(
|
||||
'Label identifier field metadata id does not exist',
|
||||
FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
assertMutationNotOnRemoteObject(objectMetadataItemWithFieldMaps);
|
||||
|
||||
assertDoesNotNullifyDefaultValueForNonNullableField({
|
||||
isNullable: existingFieldMetadata.isNullable,
|
||||
defaultValueFromUpdate: fieldMetadataInput.defaultValue,
|
||||
});
|
||||
|
||||
if (fieldMetadataInput.isActive === false) {
|
||||
checkCanDeactivateFieldOrThrow({
|
||||
labelIdentifierFieldMetadataId:
|
||||
objectMetadataItemWithFieldMaps.labelIdentifierFieldMetadataId,
|
||||
existingFieldMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
const updatableFieldInput =
|
||||
existingFieldMetadata.isCustom === false
|
||||
? buildUpdatableStandardFieldInput(
|
||||
fieldMetadataInput,
|
||||
existingFieldMetadata,
|
||||
)
|
||||
: fieldMetadataInput;
|
||||
|
||||
const optionsForUpdate = isDefined(fieldMetadataInput.options)
|
||||
? prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
|
||||
: undefined;
|
||||
const defaultValueForUpdate =
|
||||
updatableFieldInput.defaultValue !== undefined
|
||||
? updatableFieldInput.defaultValue
|
||||
: existingFieldMetadata.defaultValue;
|
||||
|
||||
const fieldMetadataForUpdate = {
|
||||
...updatableFieldInput,
|
||||
defaultValue: defaultValueForUpdate,
|
||||
...optionsForUpdate,
|
||||
};
|
||||
|
||||
await this.fieldMetadataValidationService.validateFieldMetadata({
|
||||
fieldMetadataType: existingFieldMetadata.type,
|
||||
existingFieldMetadata,
|
||||
fieldMetadataInput: fieldMetadataForUpdate,
|
||||
objectMetadata: objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
const isLabelSyncedWithName =
|
||||
fieldMetadataForUpdate.isLabelSyncedWithName ??
|
||||
existingFieldMetadata.isLabelSyncedWithName;
|
||||
|
||||
if (isLabelSyncedWithName) {
|
||||
validateNameAndLabelAreSyncOrThrow(
|
||||
fieldMetadataForUpdate.label ?? existingFieldMetadata.label,
|
||||
fieldMetadataForUpdate.name ?? existingFieldMetadata.name,
|
||||
);
|
||||
}
|
||||
|
||||
await fieldMetadataRepository.update(id, fieldMetadataForUpdate);
|
||||
|
||||
const [updatedFieldMetadata] = await fieldMetadataRepository.find({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!isDefined(updatedFieldMetadata)) {
|
||||
throw new FieldMetadataException(
|
||||
'Field does not exist',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(fieldMetadataInput.name) ||
|
||||
isDefined(updatableFieldInput.options) ||
|
||||
isDefined(updatableFieldInput.defaultValue)
|
||||
) {
|
||||
await this.workspaceMigrationService.createCustomMigration(
|
||||
generateMigrationName(`update-${updatedFieldMetadata.name}`),
|
||||
fieldMetadataInput.workspaceId,
|
||||
[
|
||||
{
|
||||
name: computeObjectTargetTable(objectMetadataItemWithFieldMaps),
|
||||
action: WorkspaceMigrationTableActionType.ALTER,
|
||||
columns: this.workspaceMigrationFactory.createColumnActions(
|
||||
WorkspaceMigrationColumnActionType.ALTER,
|
||||
existingFieldMetadata,
|
||||
updatedFieldMetadata,
|
||||
),
|
||||
} satisfies WorkspaceMigrationTableAction,
|
||||
],
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
|
||||
updatedFieldMetadata.workspaceId,
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
if (fieldMetadataInput.isActive === false) {
|
||||
const viewsRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
fieldMetadataInput.workspaceId,
|
||||
'view',
|
||||
);
|
||||
|
||||
await viewsRepository.delete({
|
||||
kanbanFieldMetadataId: id,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
updatedFieldMetadata.isActive &&
|
||||
isSelectOrMultiSelectFieldMetadata(updatedFieldMetadata) &&
|
||||
isSelectOrMultiSelectFieldMetadata(existingFieldMetadata)
|
||||
) {
|
||||
await this.fieldMetadataRelatedRecordsService.updateRelatedViewGroups(
|
||||
existingFieldMetadata,
|
||||
updatedFieldMetadata,
|
||||
);
|
||||
|
||||
await this.fieldMetadataRelatedRecordsService.updateRelatedViewFilters(
|
||||
existingFieldMetadata,
|
||||
updatedFieldMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||
fieldMetadataInput.workspaceId,
|
||||
);
|
||||
|
||||
return updatedFieldMetadata;
|
||||
} catch (error) {
|
||||
if (queryRunner.isTransactionActive) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteOneField(
|
||||
input: DeleteOneFieldInput,
|
||||
workspaceId: string,
|
||||
): Promise<FieldMetadataEntity> {
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const fieldMetadataRepository =
|
||||
queryRunner.manager.getRepository<FieldMetadataEntity>(
|
||||
FieldMetadataEntity,
|
||||
);
|
||||
|
||||
const [fieldMetadata] = await fieldMetadataRepository.find({
|
||||
where: {
|
||||
id: input.id,
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
relations: [
|
||||
'object',
|
||||
'relationTargetFieldMetadata',
|
||||
'relationTargetObjectMetadata',
|
||||
],
|
||||
});
|
||||
|
||||
if (!isDefined(fieldMetadata)) {
|
||||
throw new FieldMetadataException(
|
||||
'Field does not exist',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(fieldMetadata.object)) {
|
||||
throw new FieldMetadataException(
|
||||
'Object metadata does not exist',
|
||||
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMetadata.object.labelIdentifierFieldMetadataId === fieldMetadata.id
|
||||
) {
|
||||
throw new FieldMetadataException(
|
||||
'Cannot delete, please update the label identifier field first',
|
||||
FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
|
||||
{
|
||||
userFriendlyMessage: t`Cannot delete, please update the label identifier field first`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMetadata.type === FieldMetadataType.RELATION) {
|
||||
const isManyToOneRelation =
|
||||
(fieldMetadata as FieldMetadataEntity<FieldMetadataType.RELATION>)
|
||||
.settings?.relationType === RelationType.MANY_TO_ONE;
|
||||
|
||||
if (!isDefined(fieldMetadata.relationTargetFieldMetadata)) {
|
||||
throw new FieldMetadataException(
|
||||
'Target field metadata does not exist',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
|
||||
);
|
||||
}
|
||||
|
||||
await fieldMetadataRepository.delete({
|
||||
id: In([
|
||||
fieldMetadata.id,
|
||||
fieldMetadata.relationTargetFieldMetadata.id,
|
||||
]),
|
||||
});
|
||||
|
||||
await this.workspaceMigrationService.createCustomMigration(
|
||||
generateMigrationName(`delete-${fieldMetadata.name}`),
|
||||
workspaceId,
|
||||
[
|
||||
{
|
||||
name: isManyToOneRelation
|
||||
? computeObjectTargetTable(fieldMetadata.object)
|
||||
: computeObjectTargetTable(
|
||||
fieldMetadata.relationTargetObjectMetadata,
|
||||
),
|
||||
action: WorkspaceMigrationTableActionType.ALTER,
|
||||
columns: [
|
||||
{
|
||||
action: WorkspaceMigrationColumnActionType.DROP,
|
||||
columnName: isManyToOneRelation
|
||||
? `${(fieldMetadata as FieldMetadataEntity<FieldMetadataType.RELATION>).settings?.joinColumnName}`
|
||||
: `${(fieldMetadata.relationTargetFieldMetadata as FieldMetadataEntity<FieldMetadataType.RELATION>).settings?.joinColumnName}`,
|
||||
} satisfies WorkspaceMigrationColumnDrop,
|
||||
],
|
||||
} satisfies WorkspaceMigrationTableAction,
|
||||
],
|
||||
queryRunner,
|
||||
);
|
||||
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
await fieldMetadataRepository.delete(fieldMetadata.id);
|
||||
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
|
||||
|
||||
if (!compositeType) {
|
||||
throw new Error(
|
||||
`Composite type not found for field metadata type: ${fieldMetadata.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceMigrationService.createCustomMigration(
|
||||
generateMigrationName(
|
||||
`delete-${fieldMetadata.name}-composite-columns`,
|
||||
),
|
||||
workspaceId,
|
||||
[
|
||||
{
|
||||
name: computeObjectTargetTable(fieldMetadata.object),
|
||||
action: WorkspaceMigrationTableActionType.ALTER,
|
||||
columns: compositeType.properties.map((property) => {
|
||||
return {
|
||||
action: WorkspaceMigrationColumnActionType.DROP,
|
||||
columnName: computeCompositeColumnName(
|
||||
fieldMetadata.name,
|
||||
property,
|
||||
),
|
||||
} satisfies WorkspaceMigrationColumnDrop;
|
||||
}),
|
||||
} satisfies WorkspaceMigrationTableAction,
|
||||
],
|
||||
queryRunner,
|
||||
);
|
||||
} else {
|
||||
await fieldMetadataRepository.delete(fieldMetadata.id);
|
||||
await this.workspaceMigrationService.createCustomMigration(
|
||||
generateMigrationName(`delete-${fieldMetadata.name}`),
|
||||
workspaceId,
|
||||
[
|
||||
{
|
||||
name: computeObjectTargetTable(fieldMetadata.object),
|
||||
action: WorkspaceMigrationTableActionType.ALTER,
|
||||
columns: [
|
||||
{
|
||||
action: WorkspaceMigrationColumnActionType.DROP,
|
||||
columnName: computeColumnName(fieldMetadata),
|
||||
} satisfies WorkspaceMigrationColumnDrop,
|
||||
],
|
||||
} satisfies WorkspaceMigrationTableAction,
|
||||
],
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
|
||||
workspaceId,
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
await this.viewService.resetKanbanAggregateOperationByFieldMetadataId({
|
||||
workspaceId,
|
||||
fieldMetadataId: fieldMetadata.id,
|
||||
});
|
||||
|
||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return fieldMetadata;
|
||||
} catch (error) {
|
||||
if (queryRunner.isTransactionActive) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
public async findOneWithinWorkspace(
|
||||
workspaceId: string,
|
||||
options: FindOneOptions<FieldMetadataEntity>,
|
||||
) {
|
||||
const [fieldMetadata] = await this.fieldMetadataRepository.find({
|
||||
...options,
|
||||
where: {
|
||||
...options.where,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return fieldMetadata;
|
||||
}
|
||||
|
||||
private groupFieldInputsByObjectId(
|
||||
fieldMetadataInputs: CreateFieldInput[],
|
||||
): Record<string, CreateFieldInput[]> {
|
||||
return fieldMetadataInputs.reduce(
|
||||
(acc, input) => {
|
||||
if (!acc[input.objectMetadataId]) {
|
||||
acc[input.objectMetadataId] = [];
|
||||
}
|
||||
acc[input.objectMetadataId].push(input);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, CreateFieldInput[]>,
|
||||
);
|
||||
}
|
||||
|
||||
async createMany(
|
||||
fieldMetadataInputs: CreateFieldInput[],
|
||||
): Promise<FieldMetadataEntity[]> {
|
||||
if (!fieldMetadataInputs.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { objectMetadataMaps } =
|
||||
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
|
||||
{ workspaceId: fieldMetadataInputs[0].workspaceId },
|
||||
);
|
||||
|
||||
const workspaceId = fieldMetadataInputs[0].workspaceId;
|
||||
|
||||
const isMorphRelationEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const isSomeFieldMetadatInputsMorph = fieldMetadataInputs.some(
|
||||
(fieldMetadataInput) =>
|
||||
fieldMetadataInput.type === FieldMetadataType.MORPH_RELATION,
|
||||
);
|
||||
|
||||
if (isSomeFieldMetadatInputsMorph && !isMorphRelationEnabled) {
|
||||
throw new FieldMetadataException(
|
||||
'Morph Relation feature is not enabled for this workspace',
|
||||
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const fieldMetadataRepository =
|
||||
queryRunner.manager.getRepository<FieldMetadataEntity>(
|
||||
FieldMetadataEntity,
|
||||
);
|
||||
|
||||
const inputsByObjectId =
|
||||
this.groupFieldInputsByObjectId(fieldMetadataInputs);
|
||||
const objectMetadataIds = Object.keys(inputsByObjectId);
|
||||
|
||||
const createdFieldMetadatas: FieldMetadataEntity[] = [];
|
||||
const migrationActions: WorkspaceMigrationTableAction[] = [];
|
||||
|
||||
for (const objectMetadataId of objectMetadataIds) {
|
||||
const objectMetadata = objectMetadataMaps.byId[objectMetadataId];
|
||||
|
||||
if (!isDefined(objectMetadata)) {
|
||||
throw new FieldMetadataException(
|
||||
'Object metadata does not exist',
|
||||
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const inputs = inputsByObjectId[objectMetadataId];
|
||||
|
||||
for (const fieldMetadataInput of inputs) {
|
||||
const createdFieldMetadataItems =
|
||||
await this.validateAndCreateFieldMetadataItems(
|
||||
fieldMetadataInput,
|
||||
objectMetadata,
|
||||
fieldMetadataRepository,
|
||||
objectMetadataMaps,
|
||||
);
|
||||
|
||||
createdFieldMetadatas.push(...createdFieldMetadataItems);
|
||||
|
||||
const fieldMigrationActions = await this.createMigrationActions({
|
||||
createdFieldMetadataItems,
|
||||
objectMetadataMap: objectMetadataMaps.byId,
|
||||
isRemoteCreation: fieldMetadataInput.isRemoteCreation ?? false,
|
||||
});
|
||||
|
||||
migrationActions.push(...fieldMigrationActions);
|
||||
}
|
||||
}
|
||||
|
||||
if (migrationActions.length > 0) {
|
||||
await this.workspaceMigrationService.createCustomMigration(
|
||||
generateMigrationName(`create-multiple-fields`),
|
||||
workspaceId,
|
||||
migrationActions,
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
|
||||
workspaceId,
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
await this.fieldMetadataRelatedRecordsService.createViewAndViewFields(
|
||||
createdFieldMetadatas,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return createdFieldMetadatas;
|
||||
} catch (error) {
|
||||
if (queryRunner.isTransactionActive) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAndCreateFieldMetadataItems(
|
||||
fieldMetadataInput: CreateFieldInput,
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps,
|
||||
fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
): Promise<FieldMetadataEntity[]> {
|
||||
if (!fieldMetadataInput.isRemoteCreation) {
|
||||
assertMutationNotOnRemoteObject(objectMetadata);
|
||||
}
|
||||
|
||||
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
|
||||
fieldMetadataInput.options = generateRatingOptions();
|
||||
}
|
||||
|
||||
if (fieldMetadataInput.isLabelSyncedWithName === true) {
|
||||
validateNameAndLabelAreSyncOrThrow(
|
||||
fieldMetadataInput.label,
|
||||
fieldMetadataInput.name,
|
||||
);
|
||||
}
|
||||
|
||||
const fieldMetadataForCreate =
|
||||
prepareCustomFieldMetadataForCreation(fieldMetadataInput);
|
||||
|
||||
await this.fieldMetadataValidationService.validateFieldMetadata({
|
||||
fieldMetadataType: fieldMetadataForCreate.type,
|
||||
fieldMetadataInput: fieldMetadataForCreate,
|
||||
objectMetadata,
|
||||
});
|
||||
|
||||
const isRelation =
|
||||
fieldMetadataInput.type === FieldMetadataType.RELATION ||
|
||||
fieldMetadataInput.type === FieldMetadataType.MORPH_RELATION;
|
||||
|
||||
if (!isRelation) {
|
||||
const createdFieldMetadataItem = await fieldMetadataRepository.save(
|
||||
fieldMetadataForCreate,
|
||||
);
|
||||
|
||||
return [createdFieldMetadataItem];
|
||||
}
|
||||
|
||||
if (fieldMetadataInput.type === FieldMetadataType.RELATION) {
|
||||
const relationFieldMetadataForCreate =
|
||||
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
|
||||
{
|
||||
fieldMetadataInput: fieldMetadataForCreate,
|
||||
relationCreationPayload: fieldMetadataInput.relationCreationPayload,
|
||||
objectMetadata,
|
||||
},
|
||||
);
|
||||
|
||||
await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics(
|
||||
{
|
||||
fieldMetadataInput: relationFieldMetadataForCreate,
|
||||
fieldMetadataType: fieldMetadataForCreate.type,
|
||||
objectMetadataMaps,
|
||||
},
|
||||
);
|
||||
|
||||
return await this.fieldMetadataRelationService.createRelationFieldMetadataItems(
|
||||
{
|
||||
fieldMetadataInput: relationFieldMetadataForCreate,
|
||||
objectMetadata,
|
||||
fieldMetadataRepository,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return await this.fieldMetadataMorphRelationService.createMorphRelationFieldMetadataItems(
|
||||
{
|
||||
fieldMetadataForCreate,
|
||||
morphRelationsCreationPayload:
|
||||
fieldMetadataInput.morphRelationsCreationPayload,
|
||||
objectMetadata,
|
||||
fieldMetadataRepository,
|
||||
objectMetadataMaps,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async createMigrationActions({
|
||||
createdFieldMetadataItems,
|
||||
objectMetadataMap,
|
||||
isRemoteCreation,
|
||||
}: {
|
||||
createdFieldMetadataItems: FieldMetadataEntity[];
|
||||
objectMetadataMap: Record<string, ObjectMetadataItemWithFieldMaps>;
|
||||
isRemoteCreation: boolean;
|
||||
}): Promise<WorkspaceMigrationTableAction[]> {
|
||||
if (isRemoteCreation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const migrationActions: WorkspaceMigrationTableAction[] = [];
|
||||
|
||||
for (const createdFieldMetadata of createdFieldMetadataItems) {
|
||||
if (
|
||||
isFieldMetadataEntityOfType(
|
||||
createdFieldMetadata,
|
||||
FieldMetadataType.RELATION,
|
||||
)
|
||||
) {
|
||||
const relationType = createdFieldMetadata.settings?.relationType;
|
||||
|
||||
if (relationType === RelationType.ONE_TO_MANY) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
migrationActions.push({
|
||||
name: computeObjectTargetTable(
|
||||
objectMetadataMap[createdFieldMetadata.objectMetadataId],
|
||||
),
|
||||
action: WorkspaceMigrationTableActionType.ALTER,
|
||||
columns: this.workspaceMigrationFactory.createColumnActions(
|
||||
WorkspaceMigrationColumnActionType.CREATE,
|
||||
createdFieldMetadata,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return migrationActions;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user