Files
twenty/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts
T
Marie 288f0919db Define server error messages to display in FE from the server (#12973)
Currently, when a server query or mutation from the front-end fails, the
error message defined server-side is displayed in a snackbar in the
front-end.
These error messages usually contain technical details that don't belong
to the user interface, such as "ObjectMetadataCollection not found" or
"invalid ENUM value for ...".

**BE**
In addition to the original error message that is still needed (for the
request response, debugging, sentry monitoring etc.), we add a
`displayedErrorMessage` that will be used in the snackbars. It's only
relevant to add it for the messages that will reach the FE (ie. not in
jobs or in rest api for instance) and if it can help the user sort out /
fix things (ie. we do add displayedErrorMessage for "Cannot create
multiple draft versions for the same workflow" or "Cannot delete
[field], please update the label identifier field first", but not
"Object metadata does not exist"), even if in practice in the FE users
should not be able to perform an action that will not work (ie should
not be able to save creation of multiple draft versions of the same
workflows).

**FE**
To ease the usage we replaced enqueueSnackBar with enqueueErrorSnackBar
and enqueueSuccessSnackBar with an api that only requires to pass on the
error.
If no displayedErrorMessage is specified then the default error message
is `An error occured.`
2025-07-03 12:42:10 +00:00

298 lines
9.1 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { EnumFieldMetadataUnionType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { EnumFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
type Validator<T> = {
validator: (str: T) => boolean;
message: string;
};
type FieldMetadataUpdateCreateInput = CreateFieldInput | UpdateFieldInput;
type ValidateEnumFieldMetadataArgs = {
existingFieldMetadata?: Pick<
FieldMetadataInterface,
'type' | 'isNullable' | 'defaultValue' | 'options'
>;
fieldMetadataInput: FieldMetadataUpdateCreateInput;
fieldMetadataType: EnumFieldMetadataUnionType;
};
const QUOTED_STRING_REGEX = /^['"](.*)['"]$/;
@Injectable()
export class FieldMetadataEnumValidationService {
constructor() {}
private validatorRunner<T>(
elementToValidate: T,
{ message, validator }: Validator<T>,
) {
const shouldThrow = validator(elementToValidate);
if (shouldThrow) {
throw new FieldMetadataException(
message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: message,
},
);
}
}
private validateMetadataOptionId(sanitizedId?: string) {
const validators: Validator<string>[] = [
{
validator: (id) => !isDefined(id),
message: 'Option id is required',
},
{
validator: (id) => !z.string().uuid().safeParse(id).success,
message: 'Option id is invalid',
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedId, validator),
);
}
private validateMetadataOptionLabel(sanitizedLabel: string) {
const validators: Validator<string>[] = [
{
validator: (label) => !isDefined(label),
message: t`Option label is required`,
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: t`Option label exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: t`Option label "${sanitizedLabel}" is beneath 1 character`,
},
{
validator: (label) => label.includes(','),
message: t`Label must not contain a comma`,
},
{
validator: (label) => !isNonEmptyString(label) || label === ' ',
message: t`Label must not be empty`,
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedLabel, validator),
);
}
private validateMetadataOptionValue(sanitizedValue: string) {
const validators: Validator<string>[] = [
{
validator: (value) => !isDefined(value),
message: t`Option value is required`,
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: t`Option value exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: t`Option value "${sanitizedValue}" is beneath 1 character`,
},
{
validator: (value) => !isSnakeCaseString(value),
message: `Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedValue, validator),
);
}
private validateDuplicates(options: FieldMetadataOptions) {
const fieldsToCheckForDuplicates = [
'position',
'id',
'value',
] as const satisfies (keyof FieldMetadataOptions[number])[];
const duplicatedValidators = fieldsToCheckForDuplicates.map<
Validator<FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]>
>((field) => ({
message: `Duplicated option ${field}`,
validator: () =>
new Set(options.map((option) => option[field])).size !== options.length,
}));
duplicatedValidators.forEach((validator) =>
this.validatorRunner(options, validator),
);
}
private validateFieldMetadataInputOptions(
fieldMetadataInput: FieldMetadataUpdateCreateInput,
) {
const { options } = fieldMetadataInput;
if (!isDefined(options) || options.length === 0) {
throw new FieldMetadataException(
'Options are required for enum fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
for (const option of options) {
this.validateMetadataOptionId(option.id);
this.validateMetadataOptionValue(option.value);
this.validateMetadataOptionLabel(option.label);
}
this.validateDuplicates(options);
}
private validateSelectDefaultValue(
options: FieldMetadataOptions,
defaultValue: unknown,
) {
if (typeof defaultValue !== 'string') {
throw new FieldMetadataException(
'Default value for multi-select must be a stringified array',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
const validators: Validator<string>[] = [
{
validator: (value: string) => !QUOTED_STRING_REGEX.test(value),
message: 'Default value should be as quoted string',
},
{
validator: (value: string) =>
!options.some(
(option) =>
option.value === value.replace(QUOTED_STRING_REGEX, '$1'),
),
message: `Default value "${defaultValue}" must be one of the option values`,
},
];
validators.forEach((validator) =>
this.validatorRunner(defaultValue, validator),
);
}
private validateMultiSelectDefaultValue(
options: FieldMetadataOptions,
defaultValue: unknown,
) {
if (!Array.isArray(defaultValue)) {
throw new FieldMetadataException(
'Default value for multi-select must be an array',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
const validators: Validator<string[]>[] = [
{
validator: (values) => values.length === 0,
message: 'If defined default value must contain at least one value',
},
{
validator: (values) => new Set(values).size !== values.length,
message: 'Default values must be unique',
},
];
validators.forEach((validator) =>
this.validatorRunner(defaultValue, validator),
);
defaultValue.forEach((value) => {
this.validateSelectDefaultValue(options, value);
});
}
private validateFieldMetadataDefaultValue(
fieldType: EnumFieldMetadataType,
options: FieldMetadataOptions,
defaultValue: unknown,
) {
switch (fieldType) {
case FieldMetadataType.SELECT:
this.validateSelectDefaultValue(options, defaultValue);
break;
case FieldMetadataType.MULTI_SELECT:
this.validateMultiSelectDefaultValue(options, defaultValue);
break;
case FieldMetadataType.RATING:
// TODO: Determine if RATING should be handled here
break;
default: {
assertUnreachable(
fieldType,
'Should never occur, unknown field metadata enum type',
);
}
}
}
async validateEnumFieldMetadataInput({
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
}: ValidateEnumFieldMetadataArgs) {
const isUpdate = isDefined(existingFieldMetadata);
const shouldSkipFieldMetadataInputOptionsValidation =
isUpdate && fieldMetadataInput.options === undefined;
if (!shouldSkipFieldMetadataInputOptionsValidation) {
this.validateFieldMetadataInputOptions(fieldMetadataInput);
}
if (isDefined(fieldMetadataInput.defaultValue)) {
const options =
fieldMetadataInput.options ?? existingFieldMetadata?.options;
if (!isDefined(options)) {
throw new FieldMetadataException(
'Should never occur, could not retrieve any options to validate default value',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
this.validateFieldMetadataDefaultValue(
fieldMetadataType,
options,
fieldMetadataInput.defaultValue,
);
}
}
}