fix: FieldMetadata default value and options better validation (#2785)

* fix: wip better field metadata validation

* fix: remove files

* fix: default value and options validation

* fix: small fix

* fix: try to limit patch

* fix: tests

* Update server/src/metadata/field-metadata/validators/is-field-metadata-options.validator.ts

Co-authored-by: Weiko <corentin@twenty.com>

* fix: lint

* fix: standard fields update security

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Jérémy M 2023-12-06 15:19:23 +01:00 committed by GitHub
parent b09100e3f3
commit 93decaceab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 6369 additions and 492 deletions

21
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,21 @@
{
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Server Debug",
"runtimeExecutable": "yarn",
"cwd": "${workspaceFolder}/server",
"runtimeArgs": [
"start:debug",
"--",
"--inspect-brk"
],
"console": "integratedTerminal",
"restart": true,
"protocol": "auto",
"port": 9229,
"autoAttachChildProcesses": true
}
]
}

File diff suppressed because one or more lines are too long

View File

@ -1,17 +1,54 @@
import { Catch, UnauthorizedException } from '@nestjs/common';
import { GqlExceptionFilter } from '@nestjs/graphql';
import { ArgumentsHost, Catch, HttpException } from '@nestjs/common';
import { GqlContextType, GqlExceptionFilter } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { TypeORMError } from 'typeorm';
import {
AuthenticationError,
BaseGraphQLError,
ForbiddenError,
} from 'src/filters/utils/graphql-errors.util';
const graphQLPredefinedExceptions = {
401: AuthenticationError,
403: ForbiddenError,
};
@Catch()
export class ExceptionFilter implements GqlExceptionFilter {
catch(exception: Error) {
if (exception instanceof UnauthorizedException) {
throw new GraphQLError('Unauthorized', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
catch(exception: HttpException | TypeORMError, host: ArgumentsHost) {
if (host.getType<GqlContextType>() !== 'graphql') {
return null;
}
if (exception instanceof TypeORMError) {
const error = new BaseGraphQLError(
exception.name,
'INTERNAL_SERVER_ERROR',
);
error.stack = exception.stack;
error.extensions['response'] = exception.message;
return error;
} else if (exception instanceof HttpException) {
let error: BaseGraphQLError;
if (exception.getStatus() in graphQLPredefinedExceptions) {
error = new graphQLPredefinedExceptions[exception.getStatus()](
exception.message,
);
} else {
error = new BaseGraphQLError(
exception.message,
exception.getStatus().toString(),
);
}
error.stack = exception.stack;
error.extensions['response'] = exception.getResponse();
return error;
}
return exception;

View File

@ -0,0 +1,135 @@
import {
ASTNode,
GraphQLError,
GraphQLFormattedError,
Source,
SourceLocation,
} from 'graphql';
declare module 'graphql' {
export interface GraphQLErrorExtensions {
exception?: {
code?: string;
stacktrace?: ReadonlyArray<string>;
};
}
}
export class BaseGraphQLError extends Error implements GraphQLError {
public extensions: Record<string, any>;
override readonly name!: string;
readonly locations: ReadonlyArray<SourceLocation> | undefined;
readonly path: ReadonlyArray<string | number> | undefined;
readonly source: Source | undefined;
readonly positions: ReadonlyArray<number> | undefined;
readonly nodes: ReadonlyArray<ASTNode> | undefined;
public originalError: Error | undefined;
[key: string]: any;
constructor(
message: string,
code?: string,
extensions?: Record<string, any>,
) {
super(message);
// if no name provided, use the default. defineProperty ensures that it stays non-enumerable
if (!this.name) {
Object.defineProperty(this, 'name', { value: 'ApolloError' });
}
if (extensions?.extensions) {
throw Error(
'Pass extensions directly as the third argument of the ApolloError constructor: `new ' +
'ApolloError(message, code, {myExt: value})`, not `new ApolloError(message, code, ' +
'{extensions: {myExt: value}})`',
);
}
this.extensions = { ...extensions, code };
}
toJSON(): GraphQLFormattedError {
return toGraphQLError(this).toJSON();
}
override toString(): string {
return toGraphQLError(this).toString();
}
get [Symbol.toStringTag](): string {
return this.name;
}
}
function toGraphQLError(error: BaseGraphQLError): GraphQLError {
return new GraphQLError(error.message, {
nodes: error.nodes,
source: error.source,
positions: error.positions,
path: error.path,
originalError: error.originalError,
extensions: error.extensions,
});
}
export class SyntaxError extends BaseGraphQLError {
constructor(message: string) {
super(message, 'GRAPHQL_PARSE_FAILED');
Object.defineProperty(this, 'name', { value: 'SyntaxError' });
}
}
export class ValidationError extends BaseGraphQLError {
constructor(message: string) {
super(message, 'GRAPHQL_VALIDATION_FAILED');
Object.defineProperty(this, 'name', { value: 'ValidationError' });
}
}
export class AuthenticationError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'UNAUTHENTICATED', extensions);
Object.defineProperty(this, 'name', { value: 'AuthenticationError' });
}
}
export class ForbiddenError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'FORBIDDEN', extensions);
Object.defineProperty(this, 'name', { value: 'ForbiddenError' });
}
}
export class PersistedQueryNotFoundError extends BaseGraphQLError {
constructor() {
super('PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND');
Object.defineProperty(this, 'name', {
value: 'PersistedQueryNotFoundError',
});
}
}
export class PersistedQueryNotSupportedError extends BaseGraphQLError {
constructor() {
super('PersistedQueryNotSupported', 'PERSISTED_QUERY_NOT_SUPPORTED');
Object.defineProperty(this, 'name', {
value: 'PersistedQueryNotSupportedError',
});
}
}
export class UserInputError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'BAD_USER_INPUT', extensions);
Object.defineProperty(this, 'name', { value: 'UserInputError' });
}
}

View File

@ -76,7 +76,6 @@ export class S3Driver implements StorageDriver {
return true;
} catch (error) {
console.log(error);
if (error instanceof NotFound) {
return false;
}

View File

@ -4,6 +4,7 @@ import { ValidationPipe } from '@nestjs/common';
import * as bodyParser from 'body-parser';
import { graphqlUploadExpress } from 'graphql-upload';
import bytes from 'bytes';
import { useContainer } from 'class-validator';
import { AppModule } from './app.module';
@ -19,8 +20,16 @@ const bootstrap = async () => {
: ['error', 'warn', 'log'],
});
// Apply class-validator container so that we can use injection in validators
useContainer(app.select(AppModule), { fallbackOnErrors: true });
// Apply validation pipes globally
app.useGlobalPipes(new ValidationPipe());
app.useGlobalPipes(
new ValidationPipe({
// whitelist: true,
transform: true,
}),
);
app.use(bodyParser.json({ limit: settings.storage.maxFileSize }));
app.use(

View File

@ -1,80 +1,27 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { Field, InputType, OmitType } from '@nestjs/graphql';
import { BeforeCreateOne } from '@ptc-org/nestjs-query-graphql';
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { BeforeCreateOneField } from 'src/metadata/field-metadata/hooks/before-create-one-field.hook';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { IsDefaultValue } from 'src/metadata/field-metadata/validators/is-default-value.validator';
import { FieldMetadataComplexOptions } from 'src/metadata/field-metadata/dtos/options.input';
import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
@InputType()
@BeforeCreateOne(BeforeCreateOneField)
export class CreateFieldInput {
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsEnum(FieldMetadataType)
@IsNotEmpty()
@Field(() => FieldMetadataType)
type: FieldMetadataType;
export class CreateFieldInput extends OmitType(
FieldMetadataDTO,
['id', 'createdAt', 'updatedAt'] as const,
InputType,
) {
@IsUUID()
@Field()
objectMetadataId: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isNullable?: boolean;
@IsDefaultValue({ message: 'Invalid default value for the specified type' })
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
defaultValue?: FieldMetadataDefaultValue;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FieldMetadataComplexOptions)
@Field(() => GraphQLJSON, { nullable: true })
options?: FieldMetadataOptions;
@HideField()
targetColumnMap: FieldMetadataTargetColumnMap;
@HideField()
workspaceId: string;
}
@InputType()
export class CreateOneFieldMetadataInput {
@Type(() => CreateFieldInput)
@ValidateNested()
@Field(() => CreateFieldInput, {
description: 'The record to create',
})
field!: CreateFieldInput;
}

View File

@ -0,0 +1,85 @@
import {
IsArray,
IsBoolean,
IsDate,
IsNotEmpty,
IsNumber,
IsString,
Matches,
ValidateIf,
} from 'class-validator';
export class FieldMetadataDefaultValueString {
@ValidateIf((_object, value) => value !== null)
@IsString()
value: string | null;
}
export class FieldMetadataDefaultValueNumber {
@ValidateIf((_object, value) => value !== null)
@IsNumber()
value: number | null;
}
export class FieldMetadataDefaultValueBoolean {
@ValidateIf((_object, value) => value !== null)
@IsBoolean()
value: boolean | null;
}
export class FieldMetadataDefaultValueStringArray {
@ValidateIf((_object, value) => value !== null)
@IsArray()
@IsString({ each: true })
value: string[] | null;
}
export class FieldMetadataDefaultValueDateTime {
@ValidateIf((_object, value) => value !== null)
@IsDate()
value: Date | null;
}
export class FieldMetadataDefaultValueLink {
@ValidateIf((_object, value) => value !== null)
@IsString()
label: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
url: string | null;
}
export class FieldMetadataDefaultValueCurrency {
@ValidateIf((_object, value) => value !== null)
@IsNumber()
amountMicros: number | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
currencyCode: string | null;
}
export class FieldMetadataDefaultValueFullName {
@ValidateIf((_object, value) => value !== null)
@IsString()
firstName: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
lastName: string | null;
}
export class FieldMetadataDynamicDefaultValueUuid {
@Matches('uuid')
@IsNotEmpty()
@IsString()
type: 'uuid';
}
export class FieldMetadataDynamicDefaultValueNow {
@Matches('now')
@IsNotEmpty()
@IsString()
type: 'now';
}

View File

@ -15,6 +15,16 @@ import {
QueryOptions,
Relation,
} from '@ptc-org/nestjs-query-graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Validate,
} from 'class-validator';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
@ -22,6 +32,8 @@ import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interface
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';
import { IsFieldMetadataDefaultValue } from 'src/metadata/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/metadata/field-metadata/validators/is-field-metadata-options.validator';
registerEnumType(FieldMetadataType, {
name: 'FieldMetadataType',
@ -46,52 +58,77 @@ registerEnumType(FieldMetadataType, {
@Relation('fromRelationMetadata', () => RelationMetadataDTO, {
nullable: true,
})
export class FieldMetadataDTO {
export class FieldMetadataDTO<
T extends FieldMetadataType | 'default' = 'default',
> {
@IsUUID()
@IsNotEmpty()
@IDField(() => ID)
id: string;
@IsEnum(FieldMetadataType)
@IsNotEmpty()
@Field(() => FieldMetadataType)
type: FieldMetadataType;
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description: string;
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon: string;
icon?: string;
@Field({ nullable: true, deprecationReason: 'Use label name instead' })
placeholder?: string;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isCustom?: boolean;
@FilterableField()
isCustom: boolean;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isActive?: boolean;
@FilterableField()
isActive: boolean;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isSystem?: boolean;
@FilterableField()
isSystem: boolean;
@Field()
isNullable: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isNullable?: boolean;
@Validate(IsFieldMetadataDefaultValue)
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
defaultValue?: FieldMetadataDefaultValue;
defaultValue?: FieldMetadataDefaultValue<T>;
@Validate(IsFieldMetadataOptions)
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
options?: FieldMetadataOptions;
options?: FieldMetadataOptions<T>;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}

View File

@ -1,4 +1,4 @@
import { IsString, IsNumber, IsOptional } from 'class-validator';
import { IsString, IsNumber, IsOptional, IsNotEmpty } from 'class-validator';
export class FieldMetadataDefaultOptions {
@IsOptional()
@ -8,15 +8,17 @@ export class FieldMetadataDefaultOptions {
@IsNumber()
position: number;
@IsNotEmpty()
@IsString()
label: string;
@IsNotEmpty()
@IsString()
value: string;
}
export class FieldMetadataComplexOptions extends FieldMetadataDefaultOptions {
@IsOptional()
@IsNotEmpty()
@IsString()
color: string;
}

View File

@ -1,62 +1,40 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql';
import {
IsArray,
IsBoolean,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
Field,
HideField,
ID,
InputType,
OmitType,
PartialType,
} from '@nestjs/graphql';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsUUID, ValidateNested } from 'class-validator';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { BeforeUpdateOneField } from 'src/metadata/field-metadata/hooks/before-update-one-field.hook';
import { FieldMetadataComplexOptions } from 'src/metadata/field-metadata/dtos/options.input';
import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
@InputType()
@BeforeUpdateOne(BeforeUpdateOneField)
export class UpdateFieldInput {
@IsString()
@IsOptional()
@Field({ nullable: true })
label?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
name?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isActive?: boolean;
// TODO: Add validation for this but we don't have the type actually
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
defaultValue?: FieldMetadataDefaultValue;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FieldMetadataComplexOptions)
@Field(() => GraphQLJSON, { nullable: true })
options?: FieldMetadataOptions;
export class UpdateFieldInput extends OmitType(
PartialType(FieldMetadataDTO, InputType),
['id', 'type', 'createdAt', 'updatedAt'] as const,
) {
@HideField()
id: string;
@HideField()
workspaceId: string;
}
@InputType()
export class UpdateOneFieldMetadataInput {
@IsUUID()
@IsNotEmpty()
@Field(() => ID, { description: 'The id of the record to update' })
id!: string;
@Type(() => UpdateFieldInput)
@ValidateNested()
@Field(() => UpdateFieldInput, {
description: 'The record to update',
})
update!: UpdateFieldInput;
}

View File

@ -13,12 +13,14 @@ import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metada
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { IsFieldMetadataDefaultValue } from 'src/metadata/field-metadata/validators/is-field-metadata-default-value.validator';
import { FieldMetadataResolver } from 'src/metadata/field-metadata/field-metadata.resolver';
import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
import { FieldMetadataService } from './field-metadata.service';
import { FieldMetadataEntity } from './field-metadata.entity';
import { CreateFieldInput } from './dtos/create-field.input';
import { FieldMetadataDTO } from './dtos/field-metadata.dto';
import { UpdateFieldInput } from './dtos/update-field.input';
@Module({
@ -32,7 +34,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
DataSourceModule,
TypeORMModule,
],
services: [FieldMetadataService],
services: [IsFieldMetadataDefaultValue, FieldMetadataService],
resolvers: [
{
EntityClass: FieldMetadataEntity,
@ -46,9 +48,13 @@ import { UpdateFieldInput } from './dtos/update-field.input';
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
},
update: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
},
delete: { many: { disabled: true } },
@ -57,7 +63,11 @@ import { UpdateFieldInput } from './dtos/update-field.input';
],
}),
],
providers: [FieldMetadataService],
providers: [
IsFieldMetadataDefaultValue,
FieldMetadataService,
FieldMetadataResolver,
],
exports: [FieldMetadataService],
})
export class FieldMetadataModule {}

View File

@ -0,0 +1,38 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { CreateOneFieldMetadataInput } from 'src/metadata/field-metadata/dtos/create-field.input';
import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
import { UpdateOneFieldMetadataInput } from 'src/metadata/field-metadata/dtos/update-field.input';
import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service';
@UseGuards(JwtAuthGuard)
@Resolver(() => FieldMetadataDTO)
export class FieldMetadataResolver {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
@Mutation(() => FieldMetadataDTO)
createOneField(
@Args('input') input: CreateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.fieldMetadataService.createOne({
...input.field,
workspaceId,
});
}
@Mutation(() => FieldMetadataDTO)
updateOneField(
@Args('input') input: UpdateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.fieldMetadataService.updateOne(input.id, {
...input.update,
workspaceId,
});
}
}

View File

@ -159,6 +159,15 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
throw new NotFoundException('Field does not exist');
}
if (existingFieldMetadata.isCustom === false) {
// We can only update the isActive field for standard fields
record = {
id: record.id,
isActive: record.isActive,
workspaceId: record.workspaceId,
};
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(
record.workspaceId,
@ -208,6 +217,25 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
return updatedFieldMetadata;
}
public async findOneOrFail(
id: string,
options?: FindOneOptions<FieldMetadataEntity>,
) {
const fieldMetadata = await this.fieldMetadataRepository.findOne({
...options,
where: {
...options?.where,
id,
},
});
if (!fieldMetadata) {
throw new NotFoundException('Field does not exist');
}
return fieldMetadata;
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<FieldMetadataEntity>,

View File

@ -1,28 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
BeforeCreateOneHook,
CreateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { CreateFieldInput } from 'src/metadata/field-metadata/dtos/create-field.input';
@Injectable()
export class BeforeCreateOneField<T extends CreateFieldInput>
implements BeforeCreateOneHook<T, any>
{
async run(
instance: CreateOneInputType<T>,
context: any,
): Promise<CreateOneInputType<T>> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
instance.input.workspaceId = workspaceId;
return instance;
}
}

View File

@ -1,87 +0,0 @@
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 { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service';
@Injectable()
export class BeforeUpdateOneField<T extends UpdateFieldInput>
implements BeforeUpdateOneHook<T, any>
{
constructor(readonly fieldMetadataService: FieldMetadataService) {}
// TODO: this logic could be moved to a policy guard
async run(
instance: UpdateOneInputType<T>,
context: any,
): Promise<UpdateOneInputType<T>> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const fieldMetadata =
await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!fieldMetadata) {
throw new BadRequestException('Field does not exist');
}
if (!fieldMetadata.isCustom) {
if (
Object.keys(instance.update).length === 1 &&
instance.update.hasOwnProperty('isActive') &&
instance.update.isActive !== undefined
) {
return {
id: instance.id,
update: {
isActive: instance.update.isActive,
} as T,
};
}
throw new BadRequestException(
'Only isActive field can be updated for standard fields',
);
}
this.checkIfFieldIsEditable(instance.update, fieldMetadata);
instance.update.workspaceId = workspaceId;
return instance;
}
// This is temporary until we properly use the MigrationRunner to update column names
private checkIfFieldIsEditable(
update: UpdateFieldInput,
fieldMetadata: FieldMetadataEntity,
) {
if (update.name && update.name !== fieldMetadata.name) {
throw new BadRequestException(
"Field's name can't be updated. Please create a new field instead",
);
}
if (update.label && update.label !== fieldMetadata.label) {
throw new BadRequestException(
"Field's label can't be updated. Please create a new field instead",
);
}
}
}

View File

@ -1,23 +1,17 @@
import {
FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLink,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
FieldMetadataDynamicDefaultValueNow,
FieldMetadataDynamicDefaultValueUuid,
} from 'src/metadata/field-metadata/dtos/default-value.input';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
export interface FieldMetadataDefaultValueString {
value: string;
}
export interface FieldMetadataDefaultValueNumber {
value: number;
}
export interface FieldMetadataDefaultValueBoolean {
value: boolean;
}
export interface FieldMetadataDefaultValueStringArray {
value: string[];
}
export interface FieldMetadataDefaultValueDateTime {
value: Date;
}
type FieldMetadataScalarDefaultValue =
| FieldMetadataDefaultValueString
| FieldMetadataDefaultValueNumber
@ -25,23 +19,8 @@ type FieldMetadataScalarDefaultValue =
| FieldMetadataDefaultValueDateTime;
export type FieldMetadataDynamicDefaultValue =
| { type: 'uuid' }
| { type: 'now' };
interface FieldMetadataDefaultValueLink {
label: string;
url: string;
}
interface FieldMetadataDefaultValueCurrency {
amountMicros: number;
currencyCode: string;
}
interface FieldMetadataDefaultValueFullName {
firstName: string;
lastName: string;
}
| FieldMetadataDynamicDefaultValueUuid
| FieldMetadataDynamicDefaultValueNow;
type AllFieldMetadataDefaultValueTypes =
| FieldMetadataScalarDefaultValue
@ -51,11 +30,15 @@ type AllFieldMetadataDefaultValueTypes =
| FieldMetadataDefaultValueFullName;
type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.UUID]: FieldMetadataDefaultValueString;
[FieldMetadataType.UUID]:
| FieldMetadataDefaultValueString
| FieldMetadataDynamicDefaultValueUuid;
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
[FieldMetadataType.PHONE]: FieldMetadataDefaultValueString;
[FieldMetadataType.EMAIL]: FieldMetadataDefaultValueString;
[FieldMetadataType.DATE_TIME]: FieldMetadataDefaultValueDateTime;
[FieldMetadataType.DATE_TIME]:
| FieldMetadataDefaultValueDateTime
| FieldMetadataDynamicDefaultValueNow;
[FieldMetadataType.BOOLEAN]: FieldMetadataDefaultValueBoolean;
[FieldMetadataType.NUMBER]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.NUMERIC]: FieldMetadataDefaultValueString;

View File

@ -1,9 +1,9 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { validateDefaultValueBasedOnType } from 'src/metadata/field-metadata/utils/validate-default-value-based-on-type.util';
import { validateDefaultValueForType } from 'src/metadata/field-metadata/utils/validate-default-value-for-type.util';
describe('validateDefaultValueBasedOnType', () => {
describe('validateDefaultValueForType', () => {
it('should return true for null defaultValue', () => {
expect(validateDefaultValueBasedOnType(null, FieldMetadataType.TEXT)).toBe(
expect(validateDefaultValueForType(FieldMetadataType.TEXT, null)).toBe(
true,
);
});
@ -11,135 +11,116 @@ describe('validateDefaultValueBasedOnType', () => {
// Dynamic default values
it('should validate uuid dynamic default value for UUID type', () => {
expect(
validateDefaultValueBasedOnType({ type: 'uuid' }, FieldMetadataType.UUID),
validateDefaultValueForType(FieldMetadataType.UUID, { type: 'uuid' }),
).toBe(true);
});
it('should validate now dynamic default value for DATE_TIME type', () => {
expect(
validateDefaultValueBasedOnType(
{ type: 'now' },
FieldMetadataType.DATE_TIME,
),
validateDefaultValueForType(FieldMetadataType.DATE_TIME, { type: 'now' }),
).toBe(true);
});
it('should return false for mismatched dynamic default value', () => {
expect(
validateDefaultValueBasedOnType({ type: 'now' }, FieldMetadataType.UUID),
validateDefaultValueForType(FieldMetadataType.UUID, { type: 'now' }),
).toBe(false);
});
// Static default values
it('should validate string default value for TEXT type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: 'test' },
FieldMetadataType.TEXT,
),
validateDefaultValueForType(FieldMetadataType.TEXT, { value: 'test' }),
).toBe(true);
});
it('should return false for invalid string default value for TEXT type', () => {
expect(
validateDefaultValueBasedOnType({ value: 123 }, FieldMetadataType.TEXT),
validateDefaultValueForType(FieldMetadataType.TEXT, { value: 123 }),
).toBe(false);
});
it('should validate string default value for PHONE type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: '+123456789' },
FieldMetadataType.PHONE,
),
validateDefaultValueForType(FieldMetadataType.PHONE, {
value: '+123456789',
}),
).toBe(true);
});
it('should return false for invalid string default value for PHONE type', () => {
expect(
validateDefaultValueBasedOnType({ value: 123 }, FieldMetadataType.PHONE),
validateDefaultValueForType(FieldMetadataType.PHONE, { value: 123 }),
).toBe(false);
});
it('should validate string default value for EMAIL type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: 'test@example.com' },
FieldMetadataType.EMAIL,
),
validateDefaultValueForType(FieldMetadataType.EMAIL, {
value: 'test@example.com',
}),
).toBe(true);
});
it('should return false for invalid string default value for EMAIL type', () => {
expect(
validateDefaultValueBasedOnType({ value: 123 }, FieldMetadataType.EMAIL),
validateDefaultValueForType(FieldMetadataType.EMAIL, { value: 123 }),
).toBe(false);
});
it('should validate number default value for NUMBER type', () => {
expect(
validateDefaultValueBasedOnType({ value: 100 }, FieldMetadataType.NUMBER),
validateDefaultValueForType(FieldMetadataType.NUMBER, { value: 100 }),
).toBe(true);
});
it('should return false for invalid number default value for NUMBER type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: '100' },
FieldMetadataType.NUMBER,
),
validateDefaultValueForType(FieldMetadataType.NUMBER, { value: '100' }),
).toBe(false);
});
it('should validate number default value for PROBABILITY type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: 0.5 },
FieldMetadataType.PROBABILITY,
),
validateDefaultValueForType(FieldMetadataType.PROBABILITY, {
value: 0.5,
}),
).toBe(true);
});
it('should return false for invalid number default value for PROBABILITY type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: '50%' },
FieldMetadataType.PROBABILITY,
),
validateDefaultValueForType(FieldMetadataType.PROBABILITY, {
value: '50%',
}),
).toBe(false);
});
it('should validate boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: true },
FieldMetadataType.BOOLEAN,
),
validateDefaultValueForType(FieldMetadataType.BOOLEAN, { value: true }),
).toBe(true);
});
it('should return false for invalid boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: 'true' },
FieldMetadataType.BOOLEAN,
),
validateDefaultValueForType(FieldMetadataType.BOOLEAN, { value: 'true' }),
).toBe(false);
});
// LINK type
it('should validate LINK default value', () => {
expect(
validateDefaultValueBasedOnType(
{ label: 'http://example.com', url: 'Example' },
FieldMetadataType.LINK,
),
validateDefaultValueForType(FieldMetadataType.LINK, {
label: 'http://example.com',
url: 'Example',
}),
).toBe(true);
});
it('should return false for invalid LINK default value', () => {
expect(
validateDefaultValueBasedOnType(
validateDefaultValueForType(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error Just for testing purposes
{ label: 123, url: {} },
@ -151,16 +132,16 @@ describe('validateDefaultValueBasedOnType', () => {
// CURRENCY type
it('should validate CURRENCY default value', () => {
expect(
validateDefaultValueBasedOnType(
{ amountMicros: 100, currencyCode: 'USD' },
FieldMetadataType.CURRENCY,
),
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
amountMicros: 100,
currencyCode: 'USD',
}),
).toBe(true);
});
it('should return false for invalid CURRENCY default value', () => {
expect(
validateDefaultValueBasedOnType(
validateDefaultValueForType(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error Just for testing purposes
{ amountMicros: '100', currencyCode: 'USD' },
@ -172,10 +153,9 @@ describe('validateDefaultValueBasedOnType', () => {
// Unknown type
it('should return false for unknown type', () => {
expect(
validateDefaultValueBasedOnType(
{ value: 'test' },
'unknown' as FieldMetadataType,
),
validateDefaultValueForType('unknown' as FieldMetadataType, {
value: 'test',
}),
).toBe(false);
});
});

View File

@ -1,95 +0,0 @@
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
export const validateDefaultValueBasedOnType = (
defaultValue: FieldMetadataDefaultValue,
type: FieldMetadataType,
): boolean => {
if (defaultValue === null) return true;
// Dynamic default values
if (typeof defaultValue === 'object' && 'type' in defaultValue) {
if (type === FieldMetadataType.UUID && defaultValue.type === 'uuid') {
return true;
}
if (type === FieldMetadataType.DATE_TIME && defaultValue.type === 'now') {
return true;
}
return false;
}
// Static default values
switch (type) {
case FieldMetadataType.TEXT:
case FieldMetadataType.PHONE:
case FieldMetadataType.EMAIL:
case FieldMetadataType.RATING:
case FieldMetadataType.SELECT:
case FieldMetadataType.NUMERIC:
return (
typeof defaultValue === 'object' &&
'value' in defaultValue &&
typeof defaultValue.value === 'string'
);
case FieldMetadataType.NUMBER:
case FieldMetadataType.PROBABILITY:
return (
typeof defaultValue === 'object' &&
'value' in defaultValue &&
typeof defaultValue.value === 'number'
);
case FieldMetadataType.BOOLEAN:
return (
typeof defaultValue === 'object' &&
'value' in defaultValue &&
typeof defaultValue.value === 'boolean'
);
case FieldMetadataType.DATE_TIME:
return (
typeof defaultValue === 'object' &&
'value' in defaultValue &&
defaultValue.value instanceof Date
);
case FieldMetadataType.LINK:
return (
typeof defaultValue === 'object' &&
'label' in defaultValue &&
typeof defaultValue.label === 'string' &&
'url' in defaultValue &&
typeof defaultValue.url === 'string'
);
case FieldMetadataType.CURRENCY:
return (
typeof defaultValue === 'object' &&
'amountMicros' in defaultValue &&
typeof defaultValue.amountMicros === 'number' &&
'currencyCode' in defaultValue &&
typeof defaultValue.currencyCode === 'string'
);
case FieldMetadataType.FULL_NAME:
return (
typeof defaultValue === 'object' &&
'firstName' in defaultValue &&
typeof defaultValue.firstName === 'string' &&
'lastName' in defaultValue &&
typeof defaultValue.lastName === 'string'
);
case FieldMetadataType.MULTI_SELECT:
return (
Array.isArray(defaultValue) &&
defaultValue.every((value) => typeof value === 'string')
);
default:
return false;
}
};

View File

@ -0,0 +1,64 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLink,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
FieldMetadataDynamicDefaultValueNow,
FieldMetadataDynamicDefaultValueUuid,
} from 'src/metadata/field-metadata/dtos/default-value.input';
export const defaultValueValidatorsMap = {
[FieldMetadataType.UUID]: [
FieldMetadataDefaultValueString,
FieldMetadataDynamicDefaultValueUuid,
],
[FieldMetadataType.TEXT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.PHONE]: [FieldMetadataDefaultValueString],
[FieldMetadataType.EMAIL]: [FieldMetadataDefaultValueString],
[FieldMetadataType.DATE_TIME]: [
FieldMetadataDefaultValueDateTime,
FieldMetadataDynamicDefaultValueNow,
],
[FieldMetadataType.BOOLEAN]: [FieldMetadataDefaultValueBoolean],
[FieldMetadataType.NUMBER]: [FieldMetadataDefaultValueNumber],
[FieldMetadataType.NUMERIC]: [FieldMetadataDefaultValueString],
[FieldMetadataType.PROBABILITY]: [FieldMetadataDefaultValueNumber],
[FieldMetadataType.LINK]: [FieldMetadataDefaultValueLink],
[FieldMetadataType.CURRENCY]: [FieldMetadataDefaultValueCurrency],
[FieldMetadataType.FULL_NAME]: [FieldMetadataDefaultValueFullName],
[FieldMetadataType.RATING]: [FieldMetadataDefaultValueString],
[FieldMetadataType.SELECT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray],
};
export const validateDefaultValueForType = (
type: FieldMetadataType,
defaultValue: FieldMetadataDefaultValue,
): boolean => {
if (defaultValue === null) return true;
const validators = defaultValueValidatorsMap[type];
if (!validators) return false;
const isValid = validators.some((validator) => {
const defaultValueInstance = plainToInstance<
any,
FieldMetadataDefaultValue
>(validator, defaultValue);
return validateSync(defaultValueInstance).length === 0;
});
return isValid;
};

View File

@ -0,0 +1,44 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
FieldMetadataComplexOptions,
FieldMetadataDefaultOptions,
} from 'src/metadata/field-metadata/dtos/options.input';
export const optionsValidatorsMap = {
[FieldMetadataType.RATING]: [FieldMetadataDefaultOptions],
[FieldMetadataType.SELECT]: [FieldMetadataDefaultOptions],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataComplexOptions],
};
export const validateOptionsForType = (
type: FieldMetadataType,
options: FieldMetadataOptions,
): boolean => {
if (options === null) return true;
if (!Array.isArray(options)) {
return false;
}
const validators = optionsValidatorsMap[type];
if (!validators) return false;
const isValid = options.every((option) => {
return validators.some((validator) => {
const optionsInstance = plainToInstance<any, FieldMetadataDefaultOptions>(
validator,
option,
);
return validateSync(optionsInstance).length === 0;
});
});
return isValid;
};

View File

@ -1,27 +0,0 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
import { validateDefaultValueBasedOnType } from 'src/metadata/field-metadata/utils/validate-default-value-based-on-type.util';
export const IsDefaultValue = (validationOptions?: ValidationOptions) => {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'isDefaultValue',
target: object.constructor,
propertyName: propertyName,
constraints: [],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
// Extract type value from the object
const type = (args.object as any)['type'];
return validateDefaultValueBasedOnType(value, type);
},
},
});
};
};

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { validateDefaultValueForType } from 'src/metadata/field-metadata/utils/validate-default-value-for-type.util';
@Injectable()
@ValidatorConstraint({ name: 'isFieldMetadataDefaultValue', async: true })
export class IsFieldMetadataDefaultValue
implements ValidatorConstraintInterface
{
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
async validate(
value: FieldMetadataDefaultValue,
args: ValidationArguments,
): Promise<boolean> {
// Try to extract type value from the object
let type: FieldMetadataType | null = args.object['type'];
if (!type) {
// Extract id value from the instance, should happen only when updating
const id: string | undefined = args.instance?.['id'];
if (!id) {
return false;
}
let fieldMetadata: FieldMetadataEntity;
try {
fieldMetadata = await this.fieldMetadataService.findOneOrFail(id);
} catch {
return false;
}
type = fieldMetadata.type;
}
return validateDefaultValueForType(type, value);
}
defaultMessage(): string {
return 'FieldMetadataDefaultValue is not valid';
}
}

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { ValidationArguments, ValidatorConstraint } from 'class-validator';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { validateOptionsForType } from 'src/metadata/field-metadata/utils/validate-options-for-type.util';
@Injectable()
@ValidatorConstraint({ name: 'isFieldMetadataOptions', async: true })
export class IsFieldMetadataOptions {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
async validate(
value: FieldMetadataOptions,
args: ValidationArguments,
): Promise<boolean> {
// Try to extract type value from the object
let type: FieldMetadataType | null = args.object['type'];
if (!type) {
// Extract id value from the instance, should happen only when updating
const id: string | undefined = args.instance?.['id'];
if (!id) {
return false;
}
const fieldMetadata = await this.fieldMetadataService.findOneOrFail(id);
type = fieldMetadata.type;
}
return validateOptionsForType(type, value);
}
defaultMessage(): string {
return 'FieldMetadataOptions is not valid';
}
}

View File

@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceColumnActionOptions } from 'src/metadata/workspace-migration/interfaces/workspace-column-action-options.interface';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
@ -33,7 +34,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnCreate {
const defaultValue =
fieldMetadata.defaultValue?.value ?? options?.defaultValue;
this.getDefaultValue(fieldMetadata.defaultValue) ?? options?.defaultValue;
const serializedDefaultValue = serializeDefaultValue(defaultValue);
return {
@ -51,7 +52,8 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAlter {
const defaultValue =
nextFieldMetadata.defaultValue?.value ?? options?.defaultValue;
this.getDefaultValue(nextFieldMetadata.defaultValue) ??
options?.defaultValue;
const serializedDefaultValue = serializeDefaultValue(defaultValue);
return {
@ -62,4 +64,19 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
defaultValue: serializedDefaultValue,
};
}
private getDefaultValue(
defaultValue:
| FieldMetadataDefaultValue<BasicFieldMetadataType>
| undefined
| null,
) {
if (!defaultValue) return null;
if ('type' in defaultValue) {
return defaultValue;
} else {
return defaultValue?.value;
}
}
}