Twenty standard application Objects and fields as allFlatEntityMaps ID non-agnostic (#16298)

# Introduction

Related to https://github.com/twentyhq/core-team-issues/issues/1995
This PR introduces the basis of the `twentyStandard` application as code
on demand, it's highly tied to `ids` where it will becomes workspace
agnostic following the builder and runner `universalIdentifier` refactor
later.

The goal here to allow computing the `allFlatEntityMaps` `to` of the
`twentyStandard` application on a empty workspace ( workspace creation
). Allowing installing the twenty standard app through a workspace
migration instead of passing by the sync metadata

Nothing done will be run in production for the moment if it's not the
small validation refactor we've introduced

Please note that everything introduced here will be replaced at some
point by a twenty app instance when the twenty sdk is mature enough to
handle of the edge cases we need here

## How we've proceeded
We've been iterating over every workspace entity both objects and their
fields, and transpiled them to flatEntity.
Being sure we migrate the defaultValue, settings and so on accordingly.
We've also compute all the ids in prior of the whole entities
computation so we don't face any hoisting issue.

## Current state
At the moment only handling all of the 29 standard objects and their
fields
Settings a unique universalIdentifier for all of them

Will come views, agent role targets and so on later

## `workspace:compute-twenty-standard-migration` command
This command allow generating a workspace migration that will result in
installing the twenty standard app in an empty workspace
It's temporary and aims to allow debugging for the moment we might not
keep it in the future as it is right now
It contains debug writeFileSync which is expected no worries greptile

## `LabelFieldMetadataIdentifierId`
Small refactor allowing defining the label identifier field metadata id
of a uuid field metadata type for system object, as some of our standard
object don't have a name field and don't aim to
Also please note that we might remove this build options later in the
sake of the currently installed universal identifier application that we
could compare with the deterministic twenty standard one

## `runFlatFieldMetadataValidators`
Deprecated this pattern which was redundant and not v2 friendly pattern

## Current errors that will address in upcoming PR
Current standard objects and fields metadata does not pass the
validation that we have in place, as historically the sync metadata
would directly consume the repositories and would just ignore the
validation. This is about to change.
Will handle the below errors in dedicated PRs as they will required
upgrade commands in order to migrate the data, or will handle that from
the sync metadata instead still to be determined but nothing critical
here

- camel case field metadata name
- options label invalid format

```json
{
  "status": "fail",
  "report": {
    "fieldMetadata": [
      {
        "status": "fail",
        "errors": [
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Name should be in camelCase",
            "userFriendlyMessage": {
              "id": "P+jdmX",
              "message": "Name should be in camelCase"
            },
            "value": "iCalUID"
          }
        ],
        "flatEntityMinimalInformation": {
          "id": "68dd83cd-92c8-4233-bb28-47939bab6124",
          "name": "iCalUID",
          "objectMetadataId": "11c16ab6-9176-439e-a2db-a12c5a58a524"
        },
        "type": "create_field"
      },
      {
        "status": "fail",
        "errors": [
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"email\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "email"
              }
            },
            "value": "email"
          },
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"sms\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "sms"
              }
            },
            "value": "sms"
          }
        ],
        "flatEntityMinimalInformation": {
          "id": "e3caaf2a-e07d-4146-8dfc-9eef904e82c9",
          "name": "type",
          "objectMetadataId": "4b777de5-4c7b-4af4-9b92-655c0f87512b"
        },
        "type": "create_field"
      },
      {
        "status": "fail",
        "errors": [
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"incoming\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "incoming"
              }
            },
            "value": "incoming"
          },
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"outgoing\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "outgoing"
              }
            },
            "value": "outgoing"
          }
        ],
        "flatEntityMinimalInformation": {
          "id": "d96233a4-93be-45ea-9548-3b50f3c700cf",
          "name": "direction",
          "objectMetadataId": "480a648a-d2e5-482a-992f-ef053e1b4bb0"
        },
        "type": "create_field"
      },
      {
        "status": "fail",
        "errors": [
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"from\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "from"
              }
            },
            "value": "from"
          },
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"to\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "to"
              }
            },
            "value": "to"
          },
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"cc\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "cc"
              }
            },
            "value": "cc"
          },
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Value must be in UPPER_CASE and follow snake_case \"bcc\"",
            "userFriendlyMessage": {
              "id": "UBPzFQ",
              "message": "Value must be in UPPER_CASE and follow snake_case \"{sanitizedValue}\"",
              "values": {
                "sanitizedValue": "bcc"
              }
            },
            "value": "bcc"
          }
        ],
        "flatEntityMinimalInformation": {
          "id": "961c598e-67c3-452d-8bb2-b92c0bc64404",
          "name": "role",
          "objectMetadataId": "8af8a13c-ff97-4cd3-b70d-52a7dc2924b4"
        },
        "type": "create_field"
      },
      {
        "status": "fail",
        "errors": [
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Label must not contain a comma",
            "userFriendlyMessage": {
              "id": "k731jp",
              "message": "Label must not contain a comma"
            },
            "value": "Commas and dot (1,234.56)"
          },
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Label must not contain a comma",
            "userFriendlyMessage": {
              "id": "k731jp",
              "message": "Label must not contain a comma"
            },
            "value": "Spaces and comma (1 234,56)"
          },
          {
            "code": "INVALID_FIELD_INPUT",
            "message": "Label must not contain a comma",
            "userFriendlyMessage": {
              "id": "k731jp",
              "message": "Label must not contain a comma"
            },
            "value": "Dots and comma (1.234,56)"
          }
        ],
        "flatEntityMinimalInformation": {
          "id": "7fa20caf-2597-42e3-84e5-15a91b125b9b",
          "name": "numberFormat",
          "objectMetadataId": "a6974302-9e72-461c-aa09-9390f4ff16fc"
        },
        "type": "create_field"
      }
    ],
    "objectMetadata": [],
    "view": [],
    "viewField": [],
    "viewGroup": [],
    "index": [],
    "serverlessFunction": [],
    "cronTrigger": [],
    "databaseEventTrigger": [],
    "routeTrigger": [],
    "viewFilter": [],
    "role": [],
    "roleTarget": [],
    "agent": []
  }
}
```
This commit is contained in:
Paul Rastoin
2025-12-04 17:39:12 +01:00
committed by GitHub
parent b2d785de4b
commit 28cdb02fbb
84 changed files with 10312 additions and 506 deletions
@@ -1,27 +0,0 @@
import { t } from '@lingui/core/macro';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatFieldMetadataValidators = <T>({
elementToValidate,
validators,
}: {
elementToValidate: T;
validators: FlatMetadataValidator<T>[];
}): FlatFieldMetadataValidationError[] => {
return validators.flatMap(({ validator, message }) => {
const isInvalid = validator(elementToValidate);
if (isInvalid) {
return {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t(message),
value: elementToValidate,
};
}
return [];
});
};
@@ -4,8 +4,8 @@ import { QUOTED_STRING_REGEX } from 'twenty-shared/constants';
import {
FieldMetadataType,
type EnumFieldMetadataType,
type NonNullableRequired,
type FieldMetadataOptions,
type NonNullableRequired,
} from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
@@ -18,128 +18,159 @@ import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-me
import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { runFlatFieldMetadataValidators } from 'src/engine/metadata-modules/flat-field-metadata/utils/run-flat-field-metadata-validators.util';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
const validateMetadataOptionId = (sanitizedId?: string) => {
const validators: FlatMetadataValidator<string>[] = [
{
validator: (id) => !isDefined(id),
message: msg`Option id is required`,
},
{
validator: (id) => !z.string().uuid().safeParse(id).success,
message: msg`Option id is invalid`,
},
];
const errors: FlatFieldMetadataValidationError[] = [];
return runFlatFieldMetadataValidators({
elementToValidate: sanitizedId,
validators,
});
if (!isDefined(sanitizedId)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option id is required`,
userFriendlyMessage: msg`Option id is required`,
});
}
if (!z.string().uuid().safeParse(sanitizedId).success) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option id is invalid`,
userFriendlyMessage: msg`Option id is invalid`,
value: sanitizedId,
});
}
return errors;
};
const validateMetadataOptionLabel = (
sanitizedLabel: string,
): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
if (!isDefined(sanitizedLabel)) {
return [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option label is required`,
userFriendlyMessage: msg`Option label is required`,
},
];
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option label is required`,
userFriendlyMessage: msg`Option label is required`,
});
return errors;
}
if (!isNonEmptyString(sanitizedLabel)) {
return [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option label must be a string of at least one character`,
userFriendlyMessage: msg`Option label format not supported`,
value: sanitizedLabel,
},
];
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option label must be a string of at least one character`,
userFriendlyMessage: msg`Option label format not supported`,
value: sanitizedLabel,
});
return errors;
}
const validators: FlatMetadataValidator<string>[] = [
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: msg`Option label exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: msg`Option label "${sanitizedLabel}" is beneath 1 character`,
},
{
validator: (label) => label.includes(','),
message: msg`Label must not contain a comma`,
},
{
validator: (label) => !isNonEmptyString(label) || label === ' ',
message: msg`Label must not be empty`,
},
];
if (sanitizedLabel.length > IDENTIFIER_MAX_CHAR_LENGTH) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option label exceeds 63 characters`,
userFriendlyMessage: msg`Option label exceeds 63 characters`,
value: sanitizedLabel,
});
}
return runFlatFieldMetadataValidators({
elementToValidate: sanitizedLabel,
validators,
});
if (sanitizedLabel.length < IDENTIFIER_MIN_CHAR_LENGTH) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option label "${sanitizedLabel}" is beneath 1 character`,
userFriendlyMessage: msg`Option label "${sanitizedLabel}" is beneath 1 character`,
value: sanitizedLabel,
});
}
if (sanitizedLabel.includes(',')) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Label must not contain a comma`,
userFriendlyMessage: msg`Label must not contain a comma`,
value: sanitizedLabel,
});
}
if (!isNonEmptyString(sanitizedLabel) || sanitizedLabel === ' ') {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Label must not be empty`,
userFriendlyMessage: msg`Label must not be empty`,
value: sanitizedLabel,
});
}
return errors;
};
const validateMetadataOptionValue = (
sanitizedValue: string,
): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
if (!isDefined(sanitizedValue)) {
return [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option value is required`,
userFriendlyMessage: msg`Option value is required`,
},
];
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option value is required`,
userFriendlyMessage: msg`Option value is required`,
});
return errors;
}
if (!isNonEmptyString(sanitizedValue)) {
return [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option value must be a string of at least one character`,
userFriendlyMessage: msg`Option value format not supported`,
},
];
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option value must be a string of at least one character`,
userFriendlyMessage: msg`Option value format not supported`,
value: sanitizedValue,
});
return errors;
}
const validators: FlatMetadataValidator<string>[] = [
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: msg`Option value exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: msg`Option value "${sanitizedValue}" is beneath 1 character`,
},
{
validator: (value) => !isSnakeCaseString(value),
message: msg`Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
},
];
if (sanitizedValue.length > IDENTIFIER_MAX_CHAR_LENGTH) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option value exceeds 63 characters`,
userFriendlyMessage: msg`Option value exceeds 63 characters`,
value: sanitizedValue,
});
}
return runFlatFieldMetadataValidators({
elementToValidate: sanitizedValue,
validators,
});
if (sanitizedValue.length < IDENTIFIER_MIN_CHAR_LENGTH) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Option value "${sanitizedValue}" is beneath 1 character`,
userFriendlyMessage: msg`Option value "${sanitizedValue}" is beneath 1 character`,
value: sanitizedValue,
});
}
if (!isSnakeCaseString(sanitizedValue)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
userFriendlyMessage: msg`Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
value: sanitizedValue,
});
}
return errors;
};
const validateDuplicates = (
options: FieldMetadataOptions<EnumFieldMetadataType>,
) => {
const errors: FlatFieldMetadataValidationError[] = [];
const fieldsToCheckForDuplicates = [
'position',
'id',
@@ -148,20 +179,22 @@ const validateDuplicates = (
| FieldMetadataDefaultOption[]
| FieldMetadataComplexOption[]
)[number])[];
const duplicatedValidators = fieldsToCheckForDuplicates.map<
FlatMetadataValidator<
FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]
>
>((field) => ({
message: msg`Duplicated option ${field}`,
validator: () =>
new Set(options.map((option) => option[field])).size !== options.length,
}));
return runFlatFieldMetadataValidators({
elementToValidate: options,
validators: duplicatedValidators,
});
for (const field of fieldsToCheckForDuplicates) {
const hasDuplicates =
new Set(options.map((option) => option[field])).size !== options.length;
if (hasDuplicates) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Duplicated option ${field}`,
userFriendlyMessage: msg`Duplicated option ${field}`,
value: options,
});
}
}
return errors;
};
const validateFieldMetadataInputOptions = <T extends EnumFieldMetadataType>(
@@ -200,35 +233,43 @@ const validateSelectDefaultValue = ({
options: FieldMetadataOptions<EnumFieldMetadataType>;
defaultValue: string | string[];
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
if (typeof defaultValue !== 'string') {
return [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Default value for select must be a string got ${defaultValue}`,
userFriendlyMessage: msg`Default value must be a string`,
value: defaultValue,
},
];
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Default value for select must be a string got ${defaultValue}`,
userFriendlyMessage: msg`Default value must be a string`,
value: defaultValue,
});
return errors;
}
const validators: FlatMetadataValidator<string>[] = [
{
validator: (value: string) => !QUOTED_STRING_REGEX.test(value),
message: msg`Default value should be as quoted string`,
},
{
validator: (value: string) =>
!options.some(
(option) => option.value === value.replace(QUOTED_STRING_REGEX, '$1'),
),
message: msg`Default value "${defaultValue}" must be one of the option values`,
},
];
if (!QUOTED_STRING_REGEX.test(defaultValue)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Default value should be as quoted string`,
userFriendlyMessage: msg`Default value should be as quoted string`,
value: defaultValue,
});
}
return runFlatFieldMetadataValidators({
elementToValidate: defaultValue,
validators,
});
const matchesOptionValue = options.some(
(option) =>
option.value === defaultValue.replace(QUOTED_STRING_REGEX, '$1'),
);
if (!matchesOptionValue) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Default value "${defaultValue}" must be one of the option values`,
userFriendlyMessage: msg`Default value "${defaultValue}" must be one of the option values`,
value: defaultValue,
});
}
return errors;
};
const validateMultiSelectDefaultValue = ({
@@ -238,40 +279,49 @@ const validateMultiSelectDefaultValue = ({
options: FieldMetadataOptions<EnumFieldMetadataType>;
multiSelectDefaultValue: unknown;
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
if (!Array.isArray(multiSelectDefaultValue)) {
return [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
userFriendlyMessage: msg`Multi-select field default value must be an array`,
message: `Default value for multi-select must be an array got ${multiSelectDefaultValue}`,
value: multiSelectDefaultValue,
},
];
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
userFriendlyMessage: msg`Multi-select field default value must be an array`,
message: `Default value for multi-select must be an array got ${multiSelectDefaultValue}`,
value: multiSelectDefaultValue,
});
return errors;
}
const validators: FlatMetadataValidator<string[]>[] = [
{
validator: (values) => values.length === 0,
message: msg`If defined default value must contain at least one value`,
},
{
validator: (values) => new Set(values).size !== values.length,
message: msg`Default values must be unique`,
},
];
if (multiSelectDefaultValue.length === 0) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`If defined default value must contain at least one value`,
userFriendlyMessage: msg`If defined default value must contain at least one value`,
value: multiSelectDefaultValue,
});
}
return [
runFlatFieldMetadataValidators({
elementToValidate: multiSelectDefaultValue,
validators,
}),
multiSelectDefaultValue.flatMap((defaultValue) =>
validateSelectDefaultValue({
if (
new Set(multiSelectDefaultValue).size !== multiSelectDefaultValue.length
) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Default values must be unique`,
userFriendlyMessage: msg`Default values must be unique`,
value: multiSelectDefaultValue,
});
}
for (const defaultValue of multiSelectDefaultValue) {
errors.push(
...validateSelectDefaultValue({
defaultValue,
options,
}),
),
].flat();
);
}
return errors;
};
const validateFieldMetadataDefaultValue = ({
@@ -11,6 +11,7 @@ import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modul
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getObjectFieldNamesAndJoinColumnNames } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-object-field-names-and-join-column-names.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-builder-options.type';
const getReservedCompositeFieldNames = (
objectFlatFieldMetadatas: FlatFieldMetadata[],
@@ -39,7 +40,9 @@ export const validateFlatFieldMetadataNameAvailability = ({
name,
flatFieldMetadataMaps,
flatObjectMetadata,
buildOptions,
}: {
buildOptions: WorkspaceMigrationBuilderOptions;
name: string;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
@@ -54,7 +57,10 @@ export const validateFlatFieldMetadataNameAvailability = ({
objectFlatFieldMetadatas,
);
if (reservedCompositeFieldsNames.includes(name)) {
if (
!buildOptions.isSystemBuild &&
reservedCompositeFieldsNames.includes(name)
) {
errors.push({
code: FieldMetadataExceptionCode.RESERVED_KEYWORD,
message: `Name "${name}" is reserved composite field name`,
@@ -1,21 +1,76 @@
import { msg, t } from '@lingui/core/macro';
import camelCase from 'lodash.camelcase';
import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { METADATA_NAME_VALIDATORS } from 'src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants';
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-builder-options.type';
export const validateFlatFieldMetadataName = (
name: string,
): FlatFieldMetadataValidationError[] =>
METADATA_NAME_VALIDATORS.flatMap(({ validator, message }) => {
const isInvalid = validator(name);
const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
/^[a-z][a-zA-Z0-9]*$/;
if (isInvalid) {
return {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: message.message ?? '',
userFriendlyMessage: message,
value: name,
};
}
export const validateFlatFieldMetadataName = ({
buildOptions,
name,
}: {
name: string;
buildOptions: WorkspaceMigrationBuilderOptions;
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
return [];
});
if (name.length > IDENTIFIER_MAX_CHAR_LENGTH) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Name is too long`,
userFriendlyMessage: msg`Name is too long`,
value: name,
});
}
if (name.length < IDENTIFIER_MIN_CHAR_LENGTH) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Name is too short`,
userFriendlyMessage: msg`Name is too short`,
value: name,
});
}
if (name !== camelCase(name)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Name should be in camelCase`,
userFriendlyMessage: msg`Name should be in camelCase`,
value: name,
});
}
if (
!name.match(
STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX,
)
) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`Name is not valid: it must start with lowercase letter and contain only alphanumeric letters`,
userFriendlyMessage: msg`Name is not valid: it must start with lowercase letter and contain only alphanumeric letters`,
value: name,
});
}
if (
!buildOptions.isSystemBuild &&
RESERVED_METADATA_NAME_KEYWORDS.includes(name)
) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: t`This name is reserved. Use a different name or the system will add "Custom" suffix.`,
userFriendlyMessage: msg`This name is reserved. Use a different name or the system will add "Custom" suffix.`,
value: name,
});
}
return errors;
};
@@ -9,6 +9,7 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name-availability.util';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-builder-options.type';
export const validateMorphOrRelationFlatFieldJoinColumName = ({
flatFieldMetadata,
@@ -16,7 +17,9 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({
flatFieldMetadataMaps,
flatObjectMetadataMaps,
},
buildOptions,
}: {
buildOptions: WorkspaceMigrationBuilderOptions;
flatFieldMetadata: FlatFieldMetadata<MorphOrRelationFieldMetadataType>;
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: MetadataFlatEntityAndRelatedFlatEntityMapsForValidation<'fieldMetadata'>;
}): FlatFieldMetadataValidationError[] => {
@@ -55,6 +58,7 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({
name: flatFieldMetadata.settings.joinColumnName,
flatFieldMetadataMaps,
flatObjectMetadata,
buildOptions,
}),
);
break;
@@ -18,6 +18,7 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
},
updates,
remainingFlatEntityMapsToValidate,
buildOptions,
}: FlatFieldMetadataTypeValidationArgs<MorphOrRelationFieldMetadataType>): FlatFieldMetadataValidationError[] => {
const { relationTargetFieldMetadataId, relationTargetObjectMetadataId } =
flatFieldMetadataToValidate;
@@ -92,6 +93,7 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
)
errors.push(
...validateMorphOrRelationFlatFieldJoinColumName({
buildOptions,
flatFieldMetadata: flatFieldMetadataToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatFieldMetadataMaps,
@@ -1,27 +0,0 @@
import { t } from '@lingui/core/macro';
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatObjectMetadataValidators = <T>({
elementToValidate,
validators,
}: {
elementToValidate: T;
validators: FlatMetadataValidator<T>[];
}): FlatObjectMetadataValidationError[] => {
return validators.flatMap(({ validator, message }) => {
const isInvalid = validator(elementToValidate);
if (isInvalid) {
return {
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: t(message),
value: elementToValidate,
};
}
return [];
});
};
@@ -3,23 +3,28 @@ import {
isDefined,
isLabelIdentifierFieldMetadataTypes,
} from 'twenty-shared/utils';
import { FieldMetadataType } from 'twenty-shared/types';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-builder-options.type';
export const validateFlatObjectMetadataIdentifiers = ({
flatObjectMetadata,
flatFieldMetadataMaps,
buildOptions,
}: {
flatObjectMetadata: Pick<
FlatObjectMetadata,
'labelIdentifierFieldMetadataId' | 'imageIdentifierFieldMetadataId'
>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
buildOptions: WorkspaceMigrationBuilderOptions;
}) => {
const errors: FlatObjectMetadataValidationError[] = [];
@@ -40,13 +45,27 @@ export const validateFlatObjectMetadataIdentifiers = ({
'labelIdentifierFieldMetadataId validation failed: related field metadata not found',
userFriendlyMessage: msg`Field declared as label identifier not found`,
});
} else if (!isLabelIdentifierFieldMetadataTypes(flatFieldMetadata.type)) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataId validation failed: field type not compatible',
userFriendlyMessage: msg`Field cannot be used as label identifier`,
});
} else {
if (!isLabelIdentifierFieldMetadataTypes(flatFieldMetadata.type)) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataId validation failed: field type not compatible',
userFriendlyMessage: msg`Field cannot be used as label identifier`,
});
}
if (
!buildOptions.isSystemBuild &&
isFlatFieldMetadataOfType(flatFieldMetadata, FieldMetadataType.UUID)
) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataId validation failed: field type uuid is reserved for system object metadata',
userFriendlyMessage: msg`Field cannot be used as label identifier`,
});
}
}
}
@@ -3,13 +3,9 @@ import { msg } from '@lingui/core/macro';
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type ObjectMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-object-metadata/types/object-metadata-minimal-information.type';
import { runFlatObjectMetadataValidators } from 'src/engine/metadata-modules/flat-object-metadata/utils/run-flat-object-metadata-validators.util';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
export const validateFlatObjectMetadataLabel = ({
labelPlural,
@@ -17,26 +13,31 @@ export const validateFlatObjectMetadataLabel = ({
}: Pick<FlatObjectMetadata, 'labelPlural' | 'labelSingular'> &
ObjectMetadataMinimalInformation): FlatObjectMetadataValidationError[] => {
const errors: FlatObjectMetadataValidationError[] = [];
const validators: FlatMetadataValidator<string>[] = [
{
validator: (label) => beneathDatabaseIdentifierMinimumLength(label),
message: msg`Object label is too short`,
},
{
validator: (label) => exceedsDatabaseIdentifierMaximumLength(label),
message: msg`Object label is too long`,
},
];
errors.push(
...[labelSingular, labelPlural].flatMap((label) =>
runFlatObjectMetadataValidators({
elementToValidate: label,
validators,
}),
),
);
// Validate both labelSingular and labelPlural
for (const label of [labelSingular, labelPlural]) {
// Length too short check
if (label.length < IDENTIFIER_MIN_CHAR_LENGTH) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: `Object label is too short`,
userFriendlyMessage: msg`Object label is too short`,
value: label,
});
}
// Length too long check
if (label.length > IDENTIFIER_MAX_CHAR_LENGTH) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: `Object label is too long`,
userFriendlyMessage: msg`Object label is too long`,
value: label,
});
}
}
// Check if labels are identical
const labelsAreIdentical =
labelSingular.trim().toLowerCase() === labelPlural.trim().toLowerCase();
@@ -1,11 +1,16 @@
import { msg } from '@lingui/core/macro';
import camelCase from 'lodash.camelcase';
import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type ObjectMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-object-metadata/types/object-metadata-minimal-information.type';
import { runFlatObjectMetadataValidators } from 'src/engine/metadata-modules/flat-object-metadata/utils/run-flat-object-metadata-validators.util';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { METADATA_NAME_VALIDATORS } from 'src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants';
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
/^[a-z][a-zA-Z0-9]*$/;
export const validateFlatObjectMetadataNames = ({
namePlural,
@@ -14,15 +19,64 @@ export const validateFlatObjectMetadataNames = ({
ObjectMetadataMinimalInformation) => {
const errors: FlatObjectMetadataValidationError[] = [];
errors.push(
...[nameSingular, namePlural].flatMap((name) =>
runFlatObjectMetadataValidators({
elementToValidate: name,
validators: METADATA_NAME_VALIDATORS,
}),
),
);
// Validate both nameSingular and namePlural
for (const name of [nameSingular, namePlural]) {
// Length too long check
if (name.length > IDENTIFIER_MAX_CHAR_LENGTH) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: `Name is too long`,
userFriendlyMessage: msg`Name is too long`,
value: name,
});
}
// Length too short check
if (name.length < IDENTIFIER_MIN_CHAR_LENGTH) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: `Name is too short`,
userFriendlyMessage: msg`Name is too short`,
value: name,
});
}
// CamelCase check
if (name !== camelCase(name)) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: `Name should be in camelCase`,
userFriendlyMessage: msg`Name should be in camelCase`,
value: name,
});
}
// Format check
if (
!name.match(
STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX,
)
) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: `Name is not valid: it must start with lowercase letter and contain only alphanumeric letters`,
userFriendlyMessage: msg`Name is not valid: it must start with lowercase letter and contain only alphanumeric letters`,
value: name,
});
}
// Reserved keywords check
if (RESERVED_METADATA_NAME_KEYWORDS.includes(name)) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: `This name is reserved. Use a different name or the system will add "Custom" suffix.`,
userFriendlyMessage: msg`This name is reserved. Use a different name or the system will add "Custom" suffix.`,
value: name,
});
}
}
// Check if names are identical
const namesAreIdentical =
namePlural.trim().toLowerCase() === nameSingular.trim().toLowerCase();
@@ -1,6 +0,0 @@
import { type MessageDescriptor } from '@lingui/core';
export type FlatMetadataValidator<T> = {
validator: (value: T) => boolean;
message: MessageDescriptor;
};
@@ -1,41 +0,0 @@
import { msg } from '@lingui/core/macro';
import camelCase from 'lodash.camelcase';
import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
/^[a-z][a-zA-Z0-9]*$/;
export const METADATA_NAME_VALIDATORS: FlatMetadataValidator<string>[] = [
{
message: msg`Name is too long`,
validator: (name) => exceedsDatabaseIdentifierMaximumLength(name),
},
{
message: msg`Name is too short`,
validator: (name) => beneathDatabaseIdentifierMinimumLength(name),
},
{
message: msg`Name should be in camelCase`,
validator: (name) => name !== camelCase(name),
},
{
message: msg`Name is not valid: it must start with lowercase letter and contain only alphanumeric letters`,
validator: (name) =>
!name.match(
STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX,
),
},
{
// Safety net: Catch any reserved keywords that bypass frontend sanitization
// (e.g., programmatic API access, old clients)
// Frontend auto-adds "Custom" suffix, so properly formed requests will pass
message: msg`This name is reserved. Use a different name or the system will add "Custom" suffix.`,
validator: (name) => RESERVED_METADATA_NAME_KEYWORDS.includes(name),
},
];