mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-22 21:50:43 +03:00
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:
parent
b09100e3f3
commit
93decaceab
21
.vscode/launch.json
vendored
Normal file
21
.vscode/launch.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
5601
server/patches/class-validator+0.14.0.patch
Normal file
5601
server/patches/class-validator+0.14.0.patch
Normal file
File diff suppressed because one or more lines are too long
@ -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;
|
||||
|
135
server/src/filters/utils/graphql-errors.util.ts
Normal file
135
server/src/filters/utils/graphql-errors.util.ts
Normal 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' });
|
||||
}
|
||||
}
|
@ -76,7 +76,6 @@ export class S3Driver implements StorageDriver {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
if (error instanceof NotFound) {
|
||||
return false;
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
@ -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>,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
@ -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';
|
||||
}
|
||||
}
|
@ -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';
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user