diff --git a/server/src/database/typeorm/metadata/migrations/1700565712112-addIdentifierFieldToObjectMetadata.ts b/server/src/database/typeorm/metadata/migrations/1700565712112-addIdentifierFieldToObjectMetadata.ts new file mode 100644 index 000000000..b9446f28d --- /dev/null +++ b/server/src/database/typeorm/metadata/migrations/1700565712112-addIdentifierFieldToObjectMetadata.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIdentifierFieldToObjectMetadata1700565712112 + implements MigrationInterface +{ + name = 'AddIdentifierFieldToObjectMetadata1700565712112'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" ADD "labelIdentifierFieldMetadataId" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" ADD "imageIdentifierFieldMetadataId" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "imageIdentifierFieldMetadataId"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "labelIdentifierFieldMetadataId"`, + ); + } +} diff --git a/server/src/metadata/field-metadata/dtos/field-metadata.dto.ts b/server/src/metadata/field-metadata/dtos/field-metadata.dto.ts index 482909e0f..d659e1e56 100644 --- a/server/src/metadata/field-metadata/dtos/field-metadata.dto.ts +++ b/server/src/metadata/field-metadata/dtos/field-metadata.dto.ts @@ -8,6 +8,7 @@ import { import { Authorize, + BeforeDeleteOne, FilterableField, IDField, QueryOptions, @@ -16,6 +17,7 @@ import { import { RelationMetadataDTO } from 'src/metadata/relation-metadata/dtos/relation-metadata.dto'; import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; +import { BeforeDeleteOneField } from 'src/metadata/field-metadata/hooks/before-delete-one-field.hook'; registerEnumType(FieldMetadataType, { name: 'FieldMetadataType', @@ -33,6 +35,7 @@ registerEnumType(FieldMetadataType, { disableSort: true, maxResultsSize: 1000, }) +@BeforeDeleteOne(BeforeDeleteOneField) @Relation('toRelationMetadata', () => RelationMetadataDTO, { nullable: true, }) diff --git a/server/src/metadata/field-metadata/dtos/update-field.input.ts b/server/src/metadata/field-metadata/dtos/update-field.input.ts index eebcc74d3..9c41551a3 100644 --- a/server/src/metadata/field-metadata/dtos/update-field.input.ts +++ b/server/src/metadata/field-metadata/dtos/update-field.input.ts @@ -1,8 +1,12 @@ import { Field, InputType } from '@nestjs/graphql'; +import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { BeforeUpdateOneField } from 'src/metadata/field-metadata/hooks/before-update-one-field.hook'; + @InputType() +@BeforeUpdateOne(BeforeUpdateOneField) export class UpdateFieldInput { @IsString() @IsOptional() diff --git a/server/src/metadata/field-metadata/field-metadata.service.ts b/server/src/metadata/field-metadata/field-metadata.service.ts index f6e4cd885..b208e477e 100644 --- a/server/src/metadata/field-metadata/field-metadata.service.ts +++ b/server/src/metadata/field-metadata/field-metadata.service.ts @@ -1,5 +1,4 @@ import { - BadRequestException, ConflictException, Injectable, NotFoundException, @@ -8,12 +7,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; -import { DeleteOneOptions } from '@ptc-org/nestjs-query-core'; import { WorkspaceMigrationRunnerService } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.service'; import { WorkspaceMigrationService } from 'src/metadata/workspace-migration/workspace-migration.service'; import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; -import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto'; import { CreateFieldInput } from 'src/metadata/field-metadata/dtos/create-field.input'; import { WorkspaceMigrationTableAction } from 'src/metadata/workspace-migration/workspace-migration.entity'; import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util'; @@ -34,30 +31,6 @@ export class FieldMetadataService extends TypeOrmQueryService | undefined, - ): Promise { - const fieldMetadata = await this.fieldMetadataRepository.findOne({ - where: { id }, - }); - if (!fieldMetadata) { - throw new NotFoundException('Field does not exist'); - } - - if (!fieldMetadata.isCustom) { - throw new BadRequestException("Standard fields can't be deleted"); - } - - if (fieldMetadata.isActive) { - throw new BadRequestException("Active fields can't be deleted"); - } - - // TODO: delete associated relation-metadata and field-metadata from the relation - - return super.deleteOne(id, opts); - } - override async createOne( record: CreateFieldInput, ): Promise { @@ -108,6 +81,15 @@ export class FieldMetadataService extends TypeOrmQueryService { + constructor(readonly fieldMetadataService: FieldMetadataService) {} + + async run( + instance: DeleteOneInputType, + context: any, + ): Promise { + const workspaceId = context?.req?.user?.workspace?.id; + + if (!workspaceId) { + throw new UnauthorizedException(); + } + + const fieldMetadata = + await this.fieldMetadataService.findOneWithinWorkspace( + instance.id.toString(), + workspaceId, + ); + + if (!fieldMetadata) { + throw new BadRequestException('Field does not exist'); + } + + if (!fieldMetadata.isCustom) { + throw new BadRequestException("Standard Fields can't be deleted"); + } + + if (fieldMetadata.isActive) { + throw new BadRequestException("Active fields can't be deleted"); + } + + if (fieldMetadata.type === FieldMetadataType.RELATION) { + throw new BadRequestException( + "Relation fields can't be deleted, you need to delete the RelationMetadata instead", + ); + } + + return instance; + } +} diff --git a/server/src/metadata/field-metadata/hooks/before-update-one-field.hook.ts b/server/src/metadata/field-metadata/hooks/before-update-one-field.hook.ts new file mode 100644 index 000000000..bac26cb28 --- /dev/null +++ b/server/src/metadata/field-metadata/hooks/before-update-one-field.hook.ts @@ -0,0 +1,57 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +import { + BeforeUpdateOneHook, + UpdateOneInputType, +} from '@ptc-org/nestjs-query-graphql'; + +import { UpdateFieldInput } from 'src/metadata/field-metadata/dtos/update-field.input'; +import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service'; + +@Injectable() +export class BeforeUpdateOneField + implements BeforeUpdateOneHook +{ + constructor(readonly fieldMetadataService: FieldMetadataService) {} + + // TODO: this logic could be moved to a policy guard + async run( + instance: UpdateOneInputType, + context: any, + ): Promise> { + const workspaceId = context?.req?.user?.workspace?.id; + + if (!workspaceId) { + throw new UnauthorizedException(); + } + + const fieldMetadata = + await this.fieldMetadataService.findOneWithinWorkspace( + instance.id.toString(), + workspaceId, + ); + + if (!fieldMetadata) { + throw new BadRequestException('Field does not exist'); + } + + if (!fieldMetadata.isCustom) { + throw new BadRequestException("Standard Fields can't be updated"); + } + + this.checkIfFieldIsEditable(instance.update); + + return instance; + } + + // This is temporary until we properly use the MigrationRunner to update column names + private checkIfFieldIsEditable(update: UpdateFieldInput) { + if (update.name || update.label) { + throw new BadRequestException("Field's name and label can't be updated"); + } + } +} diff --git a/server/src/metadata/object-metadata/dtos/object-metadata.dto.ts b/server/src/metadata/object-metadata/dtos/object-metadata.dto.ts index 67c6f2019..770a089ff 100644 --- a/server/src/metadata/object-metadata/dtos/object-metadata.dto.ts +++ b/server/src/metadata/object-metadata/dtos/object-metadata.dto.ts @@ -2,6 +2,7 @@ import { ObjectType, ID, Field, HideField } from '@nestjs/graphql'; import { Authorize, + BeforeDeleteOne, CursorConnection, FilterableField, IDField, @@ -9,6 +10,7 @@ import { } from '@ptc-org/nestjs-query-graphql'; import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto'; +import { BeforeDeleteOneObject } from 'src/metadata/object-metadata/hooks/before-delete-one-object.hook'; @ObjectType('object') @Authorize({ @@ -21,6 +23,7 @@ import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadat disableSort: true, maxResultsSize: 1000, }) +@BeforeDeleteOne(BeforeDeleteOneObject) @CursorConnection('fields', () => FieldMetadataDTO) export class ObjectMetadataDTO { @IDField(() => ID) diff --git a/server/src/metadata/object-metadata/dtos/update-object.input.ts b/server/src/metadata/object-metadata/dtos/update-object.input.ts index b712596b6..bccc49ea2 100644 --- a/server/src/metadata/object-metadata/dtos/update-object.input.ts +++ b/server/src/metadata/object-metadata/dtos/update-object.input.ts @@ -1,8 +1,12 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql'; +import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { BeforeUpdateOneObject } from 'src/metadata/object-metadata/hooks/before-update-one-object.hook'; @InputType() +@BeforeUpdateOne(BeforeUpdateOneObject) export class UpdateObjectInput { @IsString() @IsOptional() @@ -38,4 +42,14 @@ export class UpdateObjectInput { @IsOptional() @Field({ nullable: true }) isActive?: boolean; + + @IsUUID() + @IsOptional() + @Field({ nullable: true }) + labelIdentifierFieldMetadataId?: string; + + @IsUUID() + @IsOptional() + @Field({ nullable: true }) + imageIdentifierFieldMetadataId?: string; } diff --git a/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts b/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts index 5e2332dc1..bd51df233 100644 --- a/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts +++ b/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts @@ -5,15 +5,12 @@ import { CreateOneInputType, } from '@ptc-org/nestjs-query-graphql'; -import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { CreateObjectInput } from 'src/metadata/object-metadata/dtos/create-object.input'; @Injectable() export class BeforeCreateOneObject implements BeforeCreateOneHook { - constructor(readonly dataSourceService: DataSourceService) {} - async run( instance: CreateOneInputType, context: any, @@ -24,12 +21,6 @@ export class BeforeCreateOneObject throw new UnauthorizedException(); } - const lastDataSourceMetadata = - await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( - workspaceId, - ); - - instance.input.dataSourceId = lastDataSourceMetadata.id; instance.input.workspaceId = workspaceId; return instance; } diff --git a/server/src/metadata/object-metadata/hooks/before-delete-one-object.hook.ts b/server/src/metadata/object-metadata/hooks/before-delete-one-object.hook.ts new file mode 100644 index 000000000..643c4ab7c --- /dev/null +++ b/server/src/metadata/object-metadata/hooks/before-delete-one-object.hook.ts @@ -0,0 +1,48 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +import { + BeforeDeleteOneHook, + DeleteOneInputType, +} from '@ptc-org/nestjs-query-graphql'; + +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; + +@Injectable() +export class BeforeDeleteOneObject implements BeforeDeleteOneHook { + constructor(readonly objectMetadataService: ObjectMetadataService) {} + + async run( + instance: DeleteOneInputType, + context: any, + ): Promise { + const workspaceId = context?.req?.user?.workspace?.id; + + if (!workspaceId) { + throw new UnauthorizedException(); + } + + const objectMetadata = + await this.objectMetadataService.findOneWithinWorkspace( + instance.id.toString(), + workspaceId, + ); + + if (!objectMetadata) { + throw new BadRequestException('Object does not exist'); + } + + if (!objectMetadata.isCustom) { + throw new BadRequestException("Standard Objects can't be deleted"); + } + + if (objectMetadata.isActive) { + throw new BadRequestException("Active objects can't be deleted"); + } + + return instance; + } +} diff --git a/server/src/metadata/object-metadata/hooks/before-update-one-object.hook.ts b/server/src/metadata/object-metadata/hooks/before-update-one-object.hook.ts new file mode 100644 index 000000000..8a2ffee39 --- /dev/null +++ b/server/src/metadata/object-metadata/hooks/before-update-one-object.hook.ts @@ -0,0 +1,102 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { + BeforeUpdateOneHook, + UpdateOneInputType, +} from '@ptc-org/nestjs-query-graphql'; +import { Equal, In, Repository } from 'typeorm'; + +import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity'; +import { UpdateObjectInput } from 'src/metadata/object-metadata/dtos/update-object.input'; +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; + +@Injectable() +export class BeforeUpdateOneObject + implements BeforeUpdateOneHook +{ + constructor( + readonly objectMetadataService: ObjectMetadataService, + // TODO: Should not use the repository here + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + ) {} + + // TODO: this logic could be moved to a policy guard + async run( + instance: UpdateOneInputType, + context: any, + ): Promise> { + const workspaceId = context?.req?.user?.workspace?.id; + + if (!workspaceId) { + throw new UnauthorizedException(); + } + + const objectMetadata = + await this.objectMetadataService.findOneWithinWorkspace( + instance.id.toString(), + workspaceId, + ); + + if (!objectMetadata) { + throw new BadRequestException('Object does not exist'); + } + + if (!objectMetadata.isCustom) { + throw new BadRequestException("Standard Objects can't be updated"); + } + + if ( + instance.update.labelIdentifierFieldMetadataId || + instance.update.imageIdentifierFieldMetadataId + ) { + const fields = await this.fieldMetadataRepository.findBy({ + workspaceId: Equal(workspaceId), + objectMetadataId: Equal(instance.id.toString()), + id: In( + [ + instance.update.labelIdentifierFieldMetadataId, + instance.update.imageIdentifierFieldMetadataId, + ].filter((id) => id !== null), + ), + }); + + const fieldIds = fields.map((field) => field.id); + + if ( + instance.update.labelIdentifierFieldMetadataId && + !fieldIds.includes(instance.update.labelIdentifierFieldMetadataId) + ) { + throw new BadRequestException('This label identifier does not exist'); + } + + if ( + instance.update.imageIdentifierFieldMetadataId && + !fieldIds.includes(instance.update.imageIdentifierFieldMetadataId) + ) { + throw new BadRequestException('This image identifier does not exist'); + } + } + + this.checkIfFieldIsEditable(instance.update); + + return instance; + } + + // This is temporary until we properly use the MigrationRunner to update column names + private checkIfFieldIsEditable(update: UpdateObjectInput) { + if ( + update.nameSingular || + update.namePlural || + update.labelSingular || + update.labelPlural + ) { + throw new BadRequestException("Object's name and label can't be updated"); + } + } +} diff --git a/server/src/metadata/object-metadata/object-metadata.entity.ts b/server/src/metadata/object-metadata/object-metadata.entity.ts index f7d809550..65ce5d496 100644 --- a/server/src/metadata/object-metadata/object-metadata.entity.ts +++ b/server/src/metadata/object-metadata/object-metadata.entity.ts @@ -58,6 +58,12 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface { @Column({ default: false }) isSystem: boolean; + @Column({ nullable: true }) + labelIdentifierFieldMetadataId?: string; + + @Column({ nullable: true }) + imageIdentifierFieldMetadataId?: string; + @Column({ nullable: false }) workspaceId: string; diff --git a/server/src/metadata/object-metadata/object-metadata.module.ts b/server/src/metadata/object-metadata/object-metadata.module.ts index a68e00fa7..31c5afc5a 100644 --- a/server/src/metadata/object-metadata/object-metadata.module.ts +++ b/server/src/metadata/object-metadata/object-metadata.module.ts @@ -12,6 +12,7 @@ import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migratio import { WorkspaceMigrationModule } from 'src/metadata/workspace-migration/workspace-migration.module'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity'; import { ObjectMetadataService } from './object-metadata.service'; import { ObjectMetadataEntity } from './object-metadata.entity'; @@ -25,7 +26,10 @@ import { ObjectMetadataDTO } from './dtos/object-metadata.dto'; NestjsQueryGraphQLModule.forFeature({ imports: [ TypeORMModule, - NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), + NestjsQueryTypeOrmModule.forFeature( + [ObjectMetadataEntity, FieldMetadataEntity], + 'metadata', + ), DataSourceModule, WorkspaceMigrationModule, WorkspaceMigrationRunnerModule, diff --git a/server/src/metadata/object-metadata/object-metadata.service.ts b/server/src/metadata/object-metadata/object-metadata.service.ts index 6780fc6ec..6f26dc677 100644 --- a/server/src/metadata/object-metadata/object-metadata.service.ts +++ b/server/src/metadata/object-metadata/object-metadata.service.ts @@ -1,8 +1,4 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Equal, In, Repository } from 'typeorm'; @@ -37,31 +33,17 @@ export class ObjectMetadataService extends TypeOrmQueryService { - const objectMetadata = await this.objectMetadataRepository.findOne({ - where: { id }, - }); - - if (!objectMetadata) { - throw new NotFoundException('Object does not exist'); - } - - if (!objectMetadata.isCustom) { - throw new BadRequestException("Standard Objects can't be deleted"); - } - - if (objectMetadata.isActive) { - throw new BadRequestException("Active objects can't be deleted"); - } - - return super.deleteOne(id); - } - override async createOne( record: CreateObjectInput, ): Promise { + const lastDataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + record.workspaceId, + ); + const createdObjectMetadata = await super.createOne({ ...record, + dataSourceId: lastDataSourceMetadata.id, targetTableName: `_${record.nameSingular}`, isActive: true, isCustom: true, @@ -208,23 +190,6 @@ export class ObjectMetadataService extends TypeOrmQueryService ObjectMetadataDTO) @Relation('toObjectMetadata', () => ObjectMetadataDTO) export class RelationMetadataDTO { diff --git a/server/src/metadata/relation-metadata/hooks/before-delete-one-field.hook.ts b/server/src/metadata/relation-metadata/hooks/before-delete-one-field.hook.ts new file mode 100644 index 000000000..6b62f6db3 --- /dev/null +++ b/server/src/metadata/relation-metadata/hooks/before-delete-one-field.hook.ts @@ -0,0 +1,54 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +import { + BeforeDeleteOneHook, + DeleteOneInputType, +} from '@ptc-org/nestjs-query-graphql'; + +import { RelationMetadataService } from 'src/metadata/relation-metadata/relation-metadata.service'; + +@Injectable() +export class BeforeDeleteOneRelation implements BeforeDeleteOneHook { + constructor(readonly relationMetadataService: RelationMetadataService) {} + + async run( + instance: DeleteOneInputType, + context: any, + ): Promise { + const workspaceId = context?.req?.user?.workspace?.id; + + if (!workspaceId) { + throw new UnauthorizedException(); + } + + const relationMetadata = + await this.relationMetadataService.findOneWithinWorkspace( + instance.id.toString(), + workspaceId, + ); + + if (!relationMetadata) { + throw new BadRequestException('Relation does not exist'); + } + + if ( + !relationMetadata.toFieldMetadata.isCustom || + !relationMetadata.fromFieldMetadata.isCustom + ) { + throw new BadRequestException("Standard Relations can't be deleted"); + } + + if ( + relationMetadata.toFieldMetadata.isActive || + relationMetadata.fromFieldMetadata.isActive + ) { + throw new BadRequestException("Active relations can't be deleted"); + } + + return instance; + } +} diff --git a/server/src/metadata/relation-metadata/relation-metadata.service.ts b/server/src/metadata/relation-metadata/relation-metadata.service.ts index 52254abb5..7df62eedb 100644 --- a/server/src/metadata/relation-metadata/relation-metadata.service.ts +++ b/server/src/metadata/relation-metadata/relation-metadata.service.ts @@ -36,6 +36,7 @@ export class RelationMetadataService extends TypeOrmQueryService { + // TODO: This logic is duplicated with the BeforeDeleteOneRelation hook const relationMetadata = await this.relationMetadataRepository.findOne({ where: { id }, relations: ['fromFieldMetadata', 'toFieldMetadata'], @@ -45,22 +46,9 @@ export class RelationMetadataService extends TypeOrmQueryService