From 5f0c9f67c9edd2079fb477bc54c46d9d73382306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Thu, 11 Jan 2024 16:41:25 +0100 Subject: [PATCH] feat: workspace health (#3344) * feat: wip workspace health * feat: split structure and metadata check * feat: check default value structure health * feat: check targetColumnMap structure health * fix: composite types doesn't have default value properly defined * feat: check default value structure health * feat: check options structure health * fix: verbose option not working properly * fix: word issue * fix: tests * fix: remove console.log * fix: TRUE and FALSE instead of YES and NO * fix: fieldMetadataType instead of type --- packages/twenty-server/src/command.module.ts | 2 + .../controllers/google-auth.controller.ts | 2 +- .../src/database/typeorm/typeorm.service.ts | 2 +- .../currency.composite-type.ts | 29 +- .../full-name.composite-type.ts | 29 +- .../field-metadata/composite-types/index.ts | 19 + .../composite-types/link.composite-type.ts | 29 +- .../dtos/default-value.input.ts | 6 +- ...lidate-default-value-based-on-type.spec.ts | 4 +- .../utils/serialize-default-value.ts | 15 +- .../serialize-type-default-value.util.ts | 18 + .../workspace-migration.factory.ts | 23 +- .../workspace-health-command.module.ts | 10 + .../commands/workspace-health.command.ts | 76 ++++ .../workspace-health-issue.interface.ts | 62 +++ .../workspace-health-options.interface.ts | 9 + .../workspace-table-definition.interface.ts | 9 + .../services/database-structure.service.ts | 91 +++++ .../services/field-metadata-health.service.ts | 355 ++++++++++++++++++ .../object-metadata-health.service.ts | 127 +++++++ ...p-field-metadata-type-to-data-type.util.ts | 34 ++ .../workspace-health/utils/valid-name.util.ts | 3 + .../workspace-health.module.ts | 27 ++ .../workspace-health.service.ts | 81 ++++ 24 files changed, 1010 insertions(+), 52 deletions(-) create mode 100644 packages/twenty-server/src/metadata/field-metadata/composite-types/index.ts create mode 100644 packages/twenty-server/src/metadata/field-metadata/utils/serialize-type-default-value.util.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/commands/workspace-health-command.module.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/commands/workspace-health.command.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-issue.interface.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-options.interface.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-table-definition.interface.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/services/database-structure.service.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/services/field-metadata-health.service.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/services/object-metadata-health.service.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/utils/map-field-metadata-type-to-data-type.util.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/utils/valid-name.util.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/workspace-health.module.ts create mode 100644 packages/twenty-server/src/workspace/workspace-health/workspace-health.service.ts diff --git a/packages/twenty-server/src/command.module.ts b/packages/twenty-server/src/command.module.ts index 1772a3de5..74b29fbc7 100644 --- a/packages/twenty-server/src/command.module.ts +++ b/packages/twenty-server/src/command.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { DatabaseCommandModule } from 'src/database/commands/database-command.module'; import { FetchWorkspaceMessagesCommandsModule } from 'src/workspace/messaging/commands/fetch-workspace-messages-commands.module'; +import { WorkspaceHealthCommandModule } from 'src/workspace/workspace-health/commands/workspace-health-command.module'; import { AppModule } from './app.module'; @@ -13,6 +14,7 @@ import { WorkspaceSyncMetadataCommandsModule } from './workspace/workspace-sync- WorkspaceSyncMetadataCommandsModule, DatabaseCommandModule, FetchWorkspaceMessagesCommandsModule, + WorkspaceHealthCommandModule, ], }) export class CommandModule {} diff --git a/packages/twenty-server/src/core/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/core/auth/controllers/google-auth.controller.ts index fb83222d0..396c43ef0 100644 --- a/packages/twenty-server/src/core/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/core/auth/controllers/google-auth.controller.ts @@ -39,7 +39,7 @@ export class GoogleAuthController { const { firstName, lastName, email, picture, workspaceInviteHash } = req.user; - const mainDataSource = await this.typeORMService.getMainDataSource(); + const mainDataSource = this.typeORMService.getMainDataSource(); const existingUser = await mainDataSource .getRepository(User) diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 76daa3882..1764abb20 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -25,7 +25,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { }); } - public async getMainDataSource(): Promise { + public getMainDataSource(): DataSource { return this.mainDataSource; } diff --git a/packages/twenty-server/src/metadata/field-metadata/composite-types/currency.composite-type.ts b/packages/twenty-server/src/metadata/field-metadata/composite-types/currency.composite-type.ts index f3fbd5f29..1798f3060 100644 --- a/packages/twenty-server/src/metadata/field-metadata/composite-types/currency.composite-type.ts +++ b/packages/twenty-server/src/metadata/field-metadata/composite-types/currency.composite-type.ts @@ -7,11 +7,14 @@ import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/gener export const currencyFields = ( fieldMetadata?: FieldMetadataInterface, ): FieldMetadataInterface[] => { - const targetColumnMap = fieldMetadata + const inferredFieldMetadata = fieldMetadata as + | FieldMetadataInterface + | undefined; + const targetColumnMap = inferredFieldMetadata ? generateTargetColumnMap( - fieldMetadata.type, - fieldMetadata.isCustom ?? false, - fieldMetadata.name, + inferredFieldMetadata.type, + inferredFieldMetadata.isCustom ?? false, + inferredFieldMetadata.name, ) : { amountMicros: 'amountMicros', @@ -29,7 +32,14 @@ export const currencyFields = ( value: targetColumnMap.amountMicros, }, isNullable: true, - } satisfies FieldMetadataInterface, + ...(inferredFieldMetadata + ? { + defaultValue: { + value: inferredFieldMetadata.defaultValue?.amountMicros ?? null, + }, + } + : {}), + } satisfies FieldMetadataInterface, { id: 'currencyCode', type: FieldMetadataType.TEXT, @@ -40,7 +50,14 @@ export const currencyFields = ( value: targetColumnMap.currencyCode, }, isNullable: true, - } satisfies FieldMetadataInterface, + ...(inferredFieldMetadata + ? { + defaultValue: { + value: inferredFieldMetadata.defaultValue?.currencyCode ?? null, + }, + } + : {}), + } satisfies FieldMetadataInterface, ]; }; diff --git a/packages/twenty-server/src/metadata/field-metadata/composite-types/full-name.composite-type.ts b/packages/twenty-server/src/metadata/field-metadata/composite-types/full-name.composite-type.ts index 775c1fec0..7848c4a46 100644 --- a/packages/twenty-server/src/metadata/field-metadata/composite-types/full-name.composite-type.ts +++ b/packages/twenty-server/src/metadata/field-metadata/composite-types/full-name.composite-type.ts @@ -7,11 +7,14 @@ import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/gener export const fullNameFields = ( fieldMetadata?: FieldMetadataInterface, ): FieldMetadataInterface[] => { - const targetColumnMap = fieldMetadata + const inferredFieldMetadata = fieldMetadata as + | FieldMetadataInterface + | undefined; + const targetColumnMap = inferredFieldMetadata ? generateTargetColumnMap( - fieldMetadata.type, - fieldMetadata.isCustom ?? false, - fieldMetadata.name, + inferredFieldMetadata.type, + inferredFieldMetadata.isCustom ?? false, + inferredFieldMetadata.name, ) : { firstName: 'firstName', @@ -29,7 +32,14 @@ export const fullNameFields = ( value: targetColumnMap.firstName, }, isNullable: true, - } satisfies FieldMetadataInterface, + ...(inferredFieldMetadata + ? { + defaultValue: { + value: inferredFieldMetadata.defaultValue?.firstName ?? null, + }, + } + : {}), + } satisfies FieldMetadataInterface, { id: 'lastName', type: FieldMetadataType.TEXT, @@ -40,7 +50,14 @@ export const fullNameFields = ( value: targetColumnMap.lastName, }, isNullable: true, - } satisfies FieldMetadataInterface, + ...(inferredFieldMetadata + ? { + defaultValue: { + value: inferredFieldMetadata.defaultValue?.lastName ?? null, + }, + } + : {}), + } satisfies FieldMetadataInterface, ]; }; diff --git a/packages/twenty-server/src/metadata/field-metadata/composite-types/index.ts b/packages/twenty-server/src/metadata/field-metadata/composite-types/index.ts new file mode 100644 index 000000000..7667055d4 --- /dev/null +++ b/packages/twenty-server/src/metadata/field-metadata/composite-types/index.ts @@ -0,0 +1,19 @@ +import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface'; + +import { currencyFields } from 'src/metadata/field-metadata/composite-types/currency.composite-type'; +import { fullNameFields } from 'src/metadata/field-metadata/composite-types/full-name.composite-type'; +import { linkFields } from 'src/metadata/field-metadata/composite-types/link.composite-type'; +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; + +export type CompositeFieldsDefinitionFunction = ( + fieldMetadata?: FieldMetadataInterface, +) => FieldMetadataInterface[]; + +export const compositeDefinitions = new Map< + string, + CompositeFieldsDefinitionFunction +>([ + [FieldMetadataType.LINK, linkFields], + [FieldMetadataType.CURRENCY, currencyFields], + [FieldMetadataType.FULL_NAME, fullNameFields], +]); diff --git a/packages/twenty-server/src/metadata/field-metadata/composite-types/link.composite-type.ts b/packages/twenty-server/src/metadata/field-metadata/composite-types/link.composite-type.ts index 44a2b0444..eed65940c 100644 --- a/packages/twenty-server/src/metadata/field-metadata/composite-types/link.composite-type.ts +++ b/packages/twenty-server/src/metadata/field-metadata/composite-types/link.composite-type.ts @@ -7,11 +7,14 @@ import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/gener export const linkFields = ( fieldMetadata?: FieldMetadataInterface, ): FieldMetadataInterface[] => { - const targetColumnMap = fieldMetadata + const inferredFieldMetadata = fieldMetadata as + | FieldMetadataInterface + | undefined; + const targetColumnMap = inferredFieldMetadata ? generateTargetColumnMap( - fieldMetadata.type, - fieldMetadata.isCustom ?? false, - fieldMetadata.name, + inferredFieldMetadata.type, + inferredFieldMetadata.isCustom ?? false, + inferredFieldMetadata.name, ) : { label: 'label', @@ -29,7 +32,14 @@ export const linkFields = ( value: targetColumnMap.label, }, isNullable: true, - } satisfies FieldMetadataInterface, + ...(inferredFieldMetadata + ? { + defaultValue: { + value: inferredFieldMetadata.defaultValue?.label ?? null, + }, + } + : {}), + } satisfies FieldMetadataInterface, { id: 'url', type: FieldMetadataType.TEXT, @@ -40,7 +50,14 @@ export const linkFields = ( value: targetColumnMap.url, }, isNullable: true, - } satisfies FieldMetadataInterface, + ...(inferredFieldMetadata + ? { + defaultValue: { + value: inferredFieldMetadata.defaultValue?.url ?? null, + }, + } + : {}), + } satisfies FieldMetadataInterface, ]; }; diff --git a/packages/twenty-server/src/metadata/field-metadata/dtos/default-value.input.ts b/packages/twenty-server/src/metadata/field-metadata/dtos/default-value.input.ts index 73508d506..b3ca24130 100644 --- a/packages/twenty-server/src/metadata/field-metadata/dtos/default-value.input.ts +++ b/packages/twenty-server/src/metadata/field-metadata/dtos/default-value.input.ts @@ -1,9 +1,11 @@ +import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsNumber, + IsNumberString, IsString, Matches, ValidateIf, @@ -52,8 +54,8 @@ export class FieldMetadataDefaultValueLink { export class FieldMetadataDefaultValueCurrency { @ValidateIf((_object, value) => value !== null) - @IsNumber() - amountMicros: number | null; + @IsNumberString() + amountMicros: string | null; @ValidateIf((_object, value) => value !== null) @IsString() diff --git a/packages/twenty-server/src/metadata/field-metadata/utils/__tests__/validate-default-value-based-on-type.spec.ts b/packages/twenty-server/src/metadata/field-metadata/utils/__tests__/validate-default-value-based-on-type.spec.ts index 73908c81e..8e7f9eea9 100644 --- a/packages/twenty-server/src/metadata/field-metadata/utils/__tests__/validate-default-value-based-on-type.spec.ts +++ b/packages/twenty-server/src/metadata/field-metadata/utils/__tests__/validate-default-value-based-on-type.spec.ts @@ -133,7 +133,7 @@ describe('validateDefaultValueForType', () => { it('should validate CURRENCY default value', () => { expect( validateDefaultValueForType(FieldMetadataType.CURRENCY, { - amountMicros: 100, + amountMicros: '100', currencyCode: 'USD', }), ).toBe(true); @@ -144,7 +144,7 @@ describe('validateDefaultValueForType', () => { validateDefaultValueForType( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Just for testing purposes - { amountMicros: '100', currencyCode: 'USD' }, + { amountMicros: 100, currencyCode: 'USD' }, FieldMetadataType.CURRENCY, ), ).toBe(false); diff --git a/packages/twenty-server/src/metadata/field-metadata/utils/serialize-default-value.ts b/packages/twenty-server/src/metadata/field-metadata/utils/serialize-default-value.ts index 0fd84064c..8c48c1abf 100644 --- a/packages/twenty-server/src/metadata/field-metadata/utils/serialize-default-value.ts +++ b/packages/twenty-server/src/metadata/field-metadata/utils/serialize-default-value.ts @@ -2,6 +2,8 @@ import { BadRequestException } from '@nestjs/common'; import { FieldMetadataDefaultSerializableValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; +import { serializeTypeDefaultValue } from 'src/metadata/field-metadata/utils/serialize-type-default-value.util'; + export const serializeDefaultValue = ( defaultValue?: FieldMetadataDefaultSerializableValue, ) => { @@ -15,14 +17,13 @@ export const serializeDefaultValue = ( typeof defaultValue === 'object' && 'type' in defaultValue ) { - switch (defaultValue.type) { - case 'uuid': - return 'public.uuid_generate_v4()'; - case 'now': - return 'now()'; - default: - throw new BadRequestException('Invalid dynamic default value type'); + const serializedTypeDefaultValue = serializeTypeDefaultValue(defaultValue); + + if (!serializedTypeDefaultValue) { + throw new BadRequestException('Invalid default value'); } + + return serializedTypeDefaultValue; } // Static default values diff --git a/packages/twenty-server/src/metadata/field-metadata/utils/serialize-type-default-value.util.ts b/packages/twenty-server/src/metadata/field-metadata/utils/serialize-type-default-value.util.ts new file mode 100644 index 000000000..1dd606ac1 --- /dev/null +++ b/packages/twenty-server/src/metadata/field-metadata/utils/serialize-type-default-value.util.ts @@ -0,0 +1,18 @@ +import { FieldMetadataDynamicDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; + +export const serializeTypeDefaultValue = ( + defaultValue?: FieldMetadataDynamicDefaultValue, +) => { + if (!defaultValue?.type) { + return null; + } + + switch (defaultValue.type) { + case 'uuid': + return 'public.uuid_generate_v4()'; + case 'now': + return 'now()'; + default: + return null; + } +}; diff --git a/packages/twenty-server/src/metadata/workspace-migration/workspace-migration.factory.ts b/packages/twenty-server/src/metadata/workspace-migration/workspace-migration.factory.ts index 4c868a42e..8d2aa7ba8 100644 --- a/packages/twenty-server/src/metadata/workspace-migration/workspace-migration.factory.ts +++ b/packages/twenty-server/src/metadata/workspace-migration/workspace-migration.factory.ts @@ -12,13 +12,7 @@ import { WorkspaceMigrationColumnActionType, } from 'src/metadata/workspace-migration/workspace-migration.entity'; import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util'; -import { fullNameFields } from 'src/metadata/field-metadata/composite-types/full-name.composite-type'; -import { currencyFields } from 'src/metadata/field-metadata/composite-types/currency.composite-type'; -import { linkFields } from 'src/metadata/field-metadata/composite-types/link.composite-type'; - -type CompositeFieldsDefinitionFunction = ( - fieldMetadata: FieldMetadataInterface, -) => FieldMetadataInterface[]; +import { compositeDefinitions } from 'src/metadata/field-metadata/composite-types'; @Injectable() export class WorkspaceMigrationFactory { @@ -30,10 +24,6 @@ export class WorkspaceMigrationFactory { options?: WorkspaceColumnActionOptions; } >; - private compositeDefinitions = new Map< - string, - CompositeFieldsDefinitionFunction - >(); constructor( private readonly basicColumnActionFactory: BasicColumnActionFactory, @@ -89,15 +79,6 @@ export class WorkspaceMigrationFactory { { factory: this.enumColumnActionFactory }, ], ]); - - this.compositeDefinitions = new Map< - string, - CompositeFieldsDefinitionFunction - >([ - [FieldMetadataType.LINK, linkFields], - [FieldMetadataType.CURRENCY, currencyFields], - [FieldMetadataType.FULL_NAME, fullNameFields], - ]); } createColumnActions( @@ -138,7 +119,7 @@ export class WorkspaceMigrationFactory { // If it's a composite field type, we need to create a column action for each of the fields if (isCompositeFieldMetadataType(alteredFieldMetadata.type)) { - const fieldMetadataSplitterFunction = this.compositeDefinitions.get( + const fieldMetadataSplitterFunction = compositeDefinitions.get( alteredFieldMetadata.type, ); diff --git a/packages/twenty-server/src/workspace/workspace-health/commands/workspace-health-command.module.ts b/packages/twenty-server/src/workspace/workspace-health/commands/workspace-health-command.module.ts new file mode 100644 index 000000000..62b93c2a4 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/commands/workspace-health-command.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { WorkspaceHealthCommand } from 'src/workspace/workspace-health/commands/workspace-health.command'; +import { WorkspaceHealthModule } from 'src/workspace/workspace-health/workspace-health.module'; + +@Module({ + imports: [WorkspaceHealthModule], + providers: [WorkspaceHealthCommand], +}) +export class WorkspaceHealthCommandModule {} diff --git a/packages/twenty-server/src/workspace/workspace-health/commands/workspace-health.command.ts b/packages/twenty-server/src/workspace/workspace-health/commands/workspace-health.command.ts new file mode 100644 index 000000000..210c8a5f2 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/commands/workspace-health.command.ts @@ -0,0 +1,76 @@ +import { Command, CommandRunner, Option } from 'nest-commander'; +import chalk from 'chalk'; + +import { WorkspaceHealthMode } from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface'; + +import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace-health.service'; + +interface WorkspaceHealthCommandOptions { + workspaceId: string; + verbose?: boolean; + mode?: WorkspaceHealthMode; +} + +@Command({ + name: 'workspace:health', + description: 'Check health of the given workspace.', +}) +export class WorkspaceHealthCommand extends CommandRunner { + constructor(private readonly workspaceHealthService: WorkspaceHealthService) { + super(); + } + + async run( + _passedParam: string[], + options: WorkspaceHealthCommandOptions, + ): Promise { + const issues = await this.workspaceHealthService.healthCheck( + options.workspaceId, + { + mode: options.mode ?? WorkspaceHealthMode.All, + }, + ); + + if (issues.length === 0) { + console.log(chalk.green('Workspace is healthy')); + } else { + console.log(chalk.red('Workspace is not healthy')); + + if (options.verbose) { + console.group(chalk.red('Issues')); + issues.forEach((issue) => { + console.log(chalk.yellow(JSON.stringify(issue, null, 2))); + }); + console.groupEnd(); + } + } + } + + @Option({ + flags: '-w, --workspace-id [workspace_id]', + description: 'workspace id', + required: true, + }) + parseWorkspaceId(value: string): string { + return value; + } + + @Option({ + flags: '-v, --verbose', + description: 'Detailed output', + required: false, + }) + parseVerbose(): boolean { + return true; + } + + @Option({ + flags: '-m, --mode [mode]', + description: 'Mode of the health check [structure, metadata, all]', + required: false, + defaultValue: WorkspaceHealthMode.All, + }) + parseMode(value: string): WorkspaceHealthMode { + return value as WorkspaceHealthMode; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-issue.interface.ts b/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-issue.interface.ts new file mode 100644 index 000000000..2f59355c0 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-issue.interface.ts @@ -0,0 +1,62 @@ +import { WorkspaceTableStructure } from 'src/workspace/workspace-health/interfaces/workspace-table-definition.interface'; + +import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; + +export enum WorkspaceHealthIssueType { + MISSING_TABLE = 'MISSING_TABLE', + TABLE_NAME_SHOULD_BE_CUSTOM = 'TABLE_NAME_SHOULD_BE_CUSTOM', + TABLE_TARGET_TABLE_NAME_NOT_VALID = 'TABLE_TARGET_TABLE_NAME_NOT_VALID', + TABLE_DATA_SOURCE_ID_NOT_VALID = 'TABLE_DATA_SOURCE_ID_NOT_VALID', + TABLE_NAME_NOT_VALID = 'TABLE_NAME_NOT_VALID', + MISSING_COLUMN = 'MISSING_COLUMN', + MISSING_INDEX = 'MISSING_INDEX', + MISSING_FOREIGN_KEY = 'MISSING_FOREIGN_KEY', + MISSING_COMPOSITE_TYPE = 'MISSING_COMPOSITE_TYPE', + COLUMN_TARGET_COLUMN_MAP_NOT_VALID = 'COLUMN_TARGET_COLUMN_MAP_NOT_VALID', + COLUMN_NAME_SHOULD_BE_CUSTOM = 'COLUMN_NAME_SHOULD_BE_CUSTOM', + COLUMN_OBJECT_REFERENCE_INVALID = 'COLUMN_OBJECT_REFERENCE_INVALID', + COLUMN_NAME_NOT_VALID = 'COLUMN_NAME_NOT_VALID', + COLUMN_TYPE_NOT_VALID = 'COLUMN_TYPE_NOT_VALID', + COLUMN_DATA_TYPE_CONFLICT = 'COLUMN_DATA_TYPE_CONFLICT', + COLUMN_NULLABILITY_CONFLICT = 'COLUMN_NULLABILITY_CONFLICT', + COLUMN_DEFAULT_VALUE_CONFLICT = 'COLUMN_DEFAULT_VALUE_CONFLICT', + COLUMN_DEFAULT_VALUE_NOT_VALID = 'COLUMN_DEFAULT_VALUE_NOT_VALID', + COLUMN_OPTIONS_NOT_VALID = 'COLUMN_OPTIONS_NOT_VALID', +} + +export interface WorkspaceHealthTableIssue { + type: + | WorkspaceHealthIssueType.MISSING_TABLE + | WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM + | WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID + | WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID + | WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID; + objectMetadata: ObjectMetadataEntity; + message: string; +} + +export interface WorkspaceHealthColumnIssue { + type: + | WorkspaceHealthIssueType.MISSING_COLUMN + | WorkspaceHealthIssueType.MISSING_INDEX + | WorkspaceHealthIssueType.MISSING_FOREIGN_KEY + | WorkspaceHealthIssueType.MISSING_COMPOSITE_TYPE + | WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID + | WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM + | WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID + | WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID + | WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID + | WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT + | WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT + | WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT + | WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID + | WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID; + fieldMetadata: FieldMetadataEntity; + columnStructure?: WorkspaceTableStructure; + message: string; +} + +export type WorkspaceHealthIssue = + | WorkspaceHealthTableIssue + | WorkspaceHealthColumnIssue; diff --git a/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-options.interface.ts b/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-options.interface.ts new file mode 100644 index 000000000..afe210156 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-health-options.interface.ts @@ -0,0 +1,9 @@ +export enum WorkspaceHealthMode { + Structure = 'structure', + Metadata = 'metadata', + All = 'all', +} + +export interface WorkspaceHealthOptions { + mode: WorkspaceHealthMode; +} diff --git a/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-table-definition.interface.ts b/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-table-definition.interface.ts new file mode 100644 index 000000000..de12ce0e2 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/interfaces/workspace-table-definition.interface.ts @@ -0,0 +1,9 @@ +export interface WorkspaceTableStructure { + tableSchema: string; + tableName: string; + columnName: string; + dataType: string; + isNullable: string; + columnDefault: string; + isPrimaryKey: string; +} diff --git a/packages/twenty-server/src/workspace/workspace-health/services/database-structure.service.ts b/packages/twenty-server/src/workspace/workspace-health/services/database-structure.service.ts new file mode 100644 index 000000000..7792a80bf --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/services/database-structure.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; + +import { ColumnType } from 'typeorm'; +import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; + +import { WorkspaceTableStructure } from 'src/workspace/workspace-health/interfaces/workspace-table-definition.interface'; +import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; + +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; +import { fieldMetadataTypeToColumnType } from 'src/metadata/workspace-migration/utils/field-metadata-type-to-column-type.util'; +import { serializeTypeDefaultValue } from 'src/metadata/field-metadata/utils/serialize-type-default-value.util'; + +@Injectable() +export class DatabaseStructureService { + constructor(private readonly typeORMService: TypeORMService) {} + + async getWorkspaceTableColumns( + schemaName: string, + tableName: string, + ): Promise { + const mainDataSource = this.typeORMService.getMainDataSource(); + + return mainDataSource.query(` + SELECT + c.table_schema as "tableSchema", + c.table_name as "tableName", + c.column_name as "columnName", + c.data_type as "dataType", + c.is_nullable as "isNullable", + c.column_default as "columnDefault", + CASE + WHEN pk.constraint_type = 'PRIMARY KEY' THEN 'TRUE' + ELSE 'FALSE' + END as "isPrimaryKey" + FROM + information_schema.columns c + LEFT JOIN + information_schema.constraint_column_usage as ccu ON c.column_name = ccu.column_name AND c.table_name = ccu.table_name + LEFT JOIN + information_schema.table_constraints as pk ON pk.constraint_name = ccu.constraint_name AND pk.constraint_type = 'PRIMARY KEY' + WHERE + c.table_schema = '${schemaName}' + AND c.table_name = '${tableName}'; + `); + } + + getPostgresDataType(fieldMedataType: FieldMetadataType): string { + const typeORMType = fieldMetadataTypeToColumnType(fieldMedataType); + const mainDataSource = this.typeORMService.getMainDataSource(); + + return mainDataSource.driver.normalizeType({ + type: typeORMType, + }); + } + + getPostgresDefault( + fieldMetadataType: FieldMetadataType, + defaultValue: FieldMetadataDefaultValue | null, + ): string | null | undefined { + const typeORMType = fieldMetadataTypeToColumnType( + fieldMetadataType, + ) as ColumnType; + const mainDataSource = this.typeORMService.getMainDataSource(); + + if (defaultValue && 'type' in defaultValue) { + const serializedDefaultValue = serializeTypeDefaultValue(defaultValue); + + // Special case for uuid_generate_v4() default value + if (serializedDefaultValue === 'public.uuid_generate_v4()') { + return 'uuid_generate_v4()'; + } + + return serializedDefaultValue; + } + + const value = + defaultValue && 'value' in defaultValue ? defaultValue.value : null; + + if (typeof value === 'number') { + return value.toString(); + } + + return mainDataSource.driver.normalizeDefault({ + type: typeORMType, + default: value, + isArray: false, + // Workaround to use normalizeDefault without a complete ColumnMetadata object + } as ColumnMetadata); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-health/services/field-metadata-health.service.ts b/packages/twenty-server/src/workspace/workspace-health/services/field-metadata-health.service.ts new file mode 100644 index 000000000..1a39caf36 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/services/field-metadata-health.service.ts @@ -0,0 +1,355 @@ +import { + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; + +import { + WorkspaceHealthIssue, + WorkspaceHealthIssueType, +} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface'; +import { WorkspaceTableStructure } from 'src/workspace/workspace-health/interfaces/workspace-table-definition.interface'; +import { WorkspaceHealthOptions } from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface'; + +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util'; +import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service'; +import { validName } from 'src/workspace/workspace-health/utils/valid-name.util'; +import { compositeDefinitions } from 'src/metadata/field-metadata/composite-types'; +import { validateDefaultValueForType } from 'src/metadata/field-metadata/utils/validate-default-value-for-type.util'; +import { isEnumFieldMetadataType } from 'src/metadata/field-metadata/utils/is-enum-field-metadata-type.util'; +import { validateOptionsForType } from 'src/metadata/field-metadata/utils/validate-options-for-type.util'; + +@Injectable() +export class FieldMetadataHealthService { + constructor( + private readonly databaseStructureService: DatabaseStructureService, + ) {} + + async healthCheck( + schemaName: string, + tableName: string, + fieldMetadataCollection: FieldMetadataEntity[], + options: WorkspaceHealthOptions, + ): Promise { + const issues: WorkspaceHealthIssue[] = []; + const workspaceTableColumns = + await this.databaseStructureService.getWorkspaceTableColumns( + schemaName, + tableName, + ); + + if (!workspaceTableColumns) { + throw new NotFoundException( + `Table ${tableName} not found in schema ${schemaName}`, + ); + } + + for (const fieldMetadata of fieldMetadataCollection) { + // Ignore relation fields for now + if ( + fieldMetadata.fromRelationMetadata || + fieldMetadata.toRelationMetadata + ) { + // TODO: Check relation fields + continue; + } + + if (isCompositeFieldMetadataType(fieldMetadata.type)) { + const compositeFieldMetadataCollection = + compositeDefinitions.get(fieldMetadata.type)?.(fieldMetadata) ?? []; + + if (options.mode === 'metadata' || options.mode === 'all') { + const targetColumnMapIssues = + this.targetColumnMapCheck(fieldMetadata); + + issues.push(...targetColumnMapIssues); + + const defaultValueIssues = + this.defaultValueHealthCheck(fieldMetadata); + + issues.push(...defaultValueIssues); + } + + for (const compositeFieldMetadata of compositeFieldMetadataCollection) { + const compositeFieldIssues = await this.healthCheckField( + tableName, + workspaceTableColumns, + compositeFieldMetadata as FieldMetadataEntity, + options, + ); + + issues.push(...compositeFieldIssues); + } + } else { + const fieldIssues = await this.healthCheckField( + tableName, + workspaceTableColumns, + fieldMetadata, + options, + ); + + issues.push(...fieldIssues); + } + } + + return issues; + } + + private async healthCheckField( + tableName: string, + workspaceTableColumns: WorkspaceTableStructure[], + fieldMetadata: FieldMetadataEntity, + options: WorkspaceHealthOptions, + ): Promise { + const issues: WorkspaceHealthIssue[] = []; + + if (options.mode === 'structure' || options.mode === 'all') { + const structureIssues = this.structureFieldCheck( + tableName, + workspaceTableColumns, + fieldMetadata, + ); + + issues.push(...structureIssues); + } + + if (options.mode === 'metadata' || options.mode === 'all') { + const metadataIssues = this.metadataFieldCheck(tableName, fieldMetadata); + + issues.push(...metadataIssues); + } + + return issues; + } + + private structureFieldCheck( + tableName: string, + workspaceTableColumns: WorkspaceTableStructure[], + fieldMetadata: FieldMetadataEntity, + ): WorkspaceHealthIssue[] { + const issues: WorkspaceHealthIssue[] = []; + const columnName = fieldMetadata.targetColumnMap.value; + const dataType = this.databaseStructureService.getPostgresDataType( + fieldMetadata.type, + ); + const defaultValue = this.databaseStructureService.getPostgresDefault( + fieldMetadata.type, + fieldMetadata.defaultValue, + ); + const isNullable = fieldMetadata.isNullable ? 'TRUE' : 'FALSE'; + // Check if column exist in database + const columnStructure = workspaceTableColumns.find( + (tableDefinition) => tableDefinition.columnName === columnName, + ); + + if (!columnStructure) { + issues.push({ + type: WorkspaceHealthIssueType.MISSING_COLUMN, + fieldMetadata, + columnStructure, + message: `Column ${columnName} not found in table ${tableName}`, + }); + + return issues; + } + + // Check if column data type is the same + if (columnStructure.dataType !== dataType) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT, + fieldMetadata, + columnStructure, + message: `Column ${columnName} type is not the same as the field metadata type "${columnStructure.dataType}" !== "${dataType}"`, + }); + } + + if (columnStructure.isNullable !== isNullable) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT, + fieldMetadata, + columnStructure, + message: `Column ${columnName} is not nullable as expected`, + }); + } + + if ( + defaultValue && + columnStructure.columnDefault && + !columnStructure.columnDefault.startsWith(defaultValue) + ) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT, + fieldMetadata, + columnStructure, + message: `Column ${columnName} default value is not the same as the field metadata default value "${columnStructure.columnDefault}" !== "${defaultValue}"`, + }); + } + + return issues; + } + + private metadataFieldCheck( + tableName: string, + fieldMetadata: FieldMetadataEntity, + ): WorkspaceHealthIssue[] { + const issues: WorkspaceHealthIssue[] = []; + const columnName = fieldMetadata.targetColumnMap.value; + const targetColumnMapIssues = this.targetColumnMapCheck(fieldMetadata); + const defaultValueIssues = this.defaultValueHealthCheck(fieldMetadata); + + if (Object.keys(fieldMetadata.targetColumnMap).length !== 1) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID, + fieldMetadata, + message: `Column ${columnName} has more than one target column map, it should only contains "value"`, + }); + } + + issues.push(...targetColumnMapIssues); + + if (fieldMetadata.isCustom && !columnName.startsWith('_')) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM, + fieldMetadata, + message: `Column ${columnName} is marked as custom in table ${tableName} but doesn't start with "_"`, + }); + } + + if (!fieldMetadata.objectMetadataId) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID, + fieldMetadata, + message: `Column ${columnName} doesn't have a valid object metadata id`, + }); + } + + if (!Object.values(FieldMetadataType).includes(fieldMetadata.type)) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID, + fieldMetadata, + message: `Column ${columnName} doesn't have a valid field metadata type`, + }); + } + + if ( + !fieldMetadata.name || + !validName(fieldMetadata.name) || + !fieldMetadata.label + ) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID, + fieldMetadata, + message: `Column ${columnName} doesn't have a valid name or label`, + }); + } + + if ( + isEnumFieldMetadataType(fieldMetadata.type) && + !validateOptionsForType(fieldMetadata.type, fieldMetadata.options) + ) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID, + fieldMetadata, + message: `Column options of ${fieldMetadata.targetColumnMap?.value} is not valid`, + }); + } + + issues.push(...defaultValueIssues); + + return issues; + } + + private targetColumnMapCheck( + fieldMetadata: FieldMetadataEntity, + ): WorkspaceHealthIssue[] { + const issues: WorkspaceHealthIssue[] = []; + + if (!fieldMetadata.targetColumnMap) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID, + fieldMetadata, + message: `Column targetColumnMap of ${fieldMetadata.name} is empty`, + }); + } + + if (!isCompositeFieldMetadataType(fieldMetadata.type)) { + if ( + Object.keys(fieldMetadata.targetColumnMap).length !== 1 && + !('value' in fieldMetadata.targetColumnMap) + ) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID, + fieldMetadata, + message: `Column targetColumnMap "${fieldMetadata.targetColumnMap}" is not valid or well structured`, + }); + } + + return issues; + } + + if ( + !this.isCompositeObjectWellStructured( + fieldMetadata.type, + fieldMetadata.targetColumnMap, + ) + ) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID, + fieldMetadata, + message: `Column targetColumnMap for composite type ${fieldMetadata.type} is not well structured "${fieldMetadata.targetColumnMap}"`, + }); + } + + return issues; + } + + private defaultValueHealthCheck( + fieldMetadata: FieldMetadataEntity, + ): WorkspaceHealthIssue[] { + const issues: WorkspaceHealthIssue[] = []; + + if ( + !validateDefaultValueForType( + fieldMetadata.type, + fieldMetadata.defaultValue, + ) + ) { + issues.push({ + type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID, + fieldMetadata, + message: `Column default value for composite type ${fieldMetadata.type} is not well structured`, + }); + } + + return issues; + } + + private isCompositeObjectWellStructured( + fieldMetadataType: FieldMetadataType, + object: any, + ): boolean { + const subFields = compositeDefinitions.get(fieldMetadataType)?.() ?? []; + + if (!object) { + return true; + } + + if (subFields.length === 0) { + throw new InternalServerErrorException( + `The composite field type ${fieldMetadataType} doesn't have any sub fields, it seems this one is not implemented in the composite definitions map`, + ); + } + + for (const subField of subFields) { + if (!object[subField.name]) { + return false; + } + } + + return true; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-health/services/object-metadata-health.service.ts b/packages/twenty-server/src/workspace/workspace-health/services/object-metadata-health.service.ts new file mode 100644 index 000000000..e6e71ae45 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/services/object-metadata-health.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from '@nestjs/common'; + +import { + WorkspaceHealthIssue, + WorkspaceHealthIssueType, +} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface'; +import { WorkspaceHealthOptions } from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { validName } from 'src/workspace/workspace-health/utils/valid-name.util'; +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; + +@Injectable() +export class ObjectMetadataHealthService { + constructor(private readonly typeORMService: TypeORMService) {} + + async healthCheck( + schemaName: string, + objectMetadata: ObjectMetadataEntity, + options: WorkspaceHealthOptions, + ): Promise { + const issues: WorkspaceHealthIssue[] = []; + + if (options.mode === 'structure' || options.mode === 'all') { + const structureIssues = await this.structureObjectCheck( + schemaName, + objectMetadata, + ); + + issues.push(...structureIssues); + } + + if (options.mode === 'metadata' || options.mode === 'all') { + const metadataIssues = this.metadataObjectCheck(objectMetadata); + + issues.push(...metadataIssues); + } + + return issues; + } + + /** + * Check the structure health of the table based on metadata + * @param schemaName + * @param objectMetadata + * @returns WorkspaceHealthIssue[] + */ + private async structureObjectCheck( + schemaName: string, + objectMetadata: ObjectMetadataEntity, + ): Promise { + const mainDataSource = this.typeORMService.getMainDataSource(); + const issues: WorkspaceHealthIssue[] = []; + + // Check if the table exist in database + const tableExist = await mainDataSource.query( + `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = '${schemaName}' AND table_name = '${objectMetadata.targetTableName}')`, + ); + + if (!tableExist) { + issues.push({ + type: WorkspaceHealthIssueType.MISSING_TABLE, + objectMetadata, + message: `Table ${objectMetadata.targetTableName} not found in schema ${schemaName}`, + }); + + return issues; + } + + return issues; + } + + /** + * Check ObjectMetadata health + * @param objectMetadata + * @returns WorkspaceHealthIssue[] + */ + private metadataObjectCheck( + objectMetadata: ObjectMetadataEntity, + ): WorkspaceHealthIssue[] { + const issues: WorkspaceHealthIssue[] = []; + + if ( + objectMetadata.isCustom && + !objectMetadata.targetTableName.startsWith('_') + ) { + issues.push({ + type: WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM, + objectMetadata, + message: `Table ${objectMetadata.targetTableName} is marked as custom but doesn't start with "_"`, + }); + } + + if (!objectMetadata.targetTableName) { + issues.push({ + type: WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID, + objectMetadata, + message: `Table ${objectMetadata.targetTableName} doesn't have a valid target table name`, + }); + } + + if (!objectMetadata.dataSourceId) { + issues.push({ + type: WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID, + objectMetadata, + message: `Table ${objectMetadata.targetTableName} doesn't have a data source`, + }); + } + + if ( + !objectMetadata.nameSingular || + !objectMetadata.namePlural || + !validName(objectMetadata.nameSingular) || + !validName(objectMetadata.namePlural) || + !objectMetadata.labelSingular || + !objectMetadata.labelPlural + ) { + issues.push({ + type: WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID, + objectMetadata, + message: `Table ${objectMetadata.targetTableName} doesn't have a valid name or label`, + }); + } + + return issues; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-health/utils/map-field-metadata-type-to-data-type.util.ts b/packages/twenty-server/src/workspace/workspace-health/utils/map-field-metadata-type-to-data-type.util.ts new file mode 100644 index 000000000..e61cd98e3 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/utils/map-field-metadata-type-to-data-type.util.ts @@ -0,0 +1,34 @@ +import { ConflictException } from '@nestjs/common'; + +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; + +export const mapFieldMetadataTypeToDataType = ( + fieldMetadataType: FieldMetadataType, +): string => { + switch (fieldMetadataType) { + case FieldMetadataType.UUID: + return 'uuid'; + case FieldMetadataType.TEXT: + return 'text'; + case FieldMetadataType.PHONE: + case FieldMetadataType.EMAIL: + return 'varchar'; + case FieldMetadataType.NUMERIC: + return 'numeric'; + case FieldMetadataType.NUMBER: + case FieldMetadataType.PROBABILITY: + return 'double precision'; + case FieldMetadataType.BOOLEAN: + return 'boolean'; + case FieldMetadataType.DATE_TIME: + return 'timestamp'; + case FieldMetadataType.RATING: + case FieldMetadataType.SELECT: + case FieldMetadataType.MULTI_SELECT: + return 'enum'; + default: + throw new ConflictException( + `Cannot convert ${fieldMetadataType} to data type.`, + ); + } +}; diff --git a/packages/twenty-server/src/workspace/workspace-health/utils/valid-name.util.ts b/packages/twenty-server/src/workspace/workspace-health/utils/valid-name.util.ts new file mode 100644 index 000000000..e53fce5d4 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/utils/valid-name.util.ts @@ -0,0 +1,3 @@ +export const validName = (name: string): boolean => { + return /^[a-zA-Z0-9_]+$/.test(name); +}; diff --git a/packages/twenty-server/src/workspace/workspace-health/workspace-health.module.ts b/packages/twenty-server/src/workspace/workspace-health/workspace-health.module.ts new file mode 100644 index 000000000..80d65188b --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/workspace-health.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; + +import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; +import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; +import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service'; +import { FieldMetadataHealthService } from 'src/workspace/workspace-health/services/field-metadata-health.service'; +import { ObjectMetadataHealthService } from 'src/workspace/workspace-health/services/object-metadata-health.service'; +import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace-health.service'; + +@Module({ + imports: [ + DataSourceModule, + TypeORMModule, + ObjectMetadataModule, + WorkspaceDataSourceModule, + ], + providers: [ + WorkspaceHealthService, + DatabaseStructureService, + ObjectMetadataHealthService, + FieldMetadataHealthService, + ], + exports: [WorkspaceHealthService], +}) +export class WorkspaceHealthModule {} diff --git a/packages/twenty-server/src/workspace/workspace-health/workspace-health.service.ts b/packages/twenty-server/src/workspace/workspace-health/workspace-health.service.ts new file mode 100644 index 000000000..ac3733b0d --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-health/workspace-health.service.ts @@ -0,0 +1,81 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { WorkspaceHealthIssue } from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface'; +import { + WorkspaceHealthMode, + WorkspaceHealthOptions, +} from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface'; + +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { DataSourceService } from 'src/metadata/data-source/data-source.service'; +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { ObjectMetadataHealthService } from 'src/workspace/workspace-health/services/object-metadata-health.service'; +import { FieldMetadataHealthService } from 'src/workspace/workspace-health/services/field-metadata-health.service'; + +@Injectable() +export class WorkspaceHealthService { + constructor( + private readonly dataSourceService: DataSourceService, + private readonly typeORMService: TypeORMService, + private readonly objectMetadataService: ObjectMetadataService, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly objectMetadataHealthService: ObjectMetadataHealthService, + private readonly fieldMetadataHealthService: FieldMetadataHealthService, + ) {} + + async healthCheck( + workspaceId: string, + options: WorkspaceHealthOptions = { mode: WorkspaceHealthMode.All }, + ): Promise { + const schemaName = + this.workspaceDataSourceService.getSchemaName(workspaceId); + const issues: WorkspaceHealthIssue[] = []; + + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspaceId, + ); + + // Check if a data source exists for this workspace + if (!dataSourceMetadata) { + throw new NotFoundException( + `Datasource for workspace id ${workspaceId} not found`, + ); + } + + // Try to connect to the data source + await this.typeORMService.connectToDataSource(dataSourceMetadata); + + const objectMetadataCollection = + await this.objectMetadataService.findManyWithinWorkspace(workspaceId); + + // Check if object metadata exists for this workspace + if (!objectMetadataCollection || objectMetadataCollection.length === 0) { + throw new NotFoundException(`Workspace with id ${workspaceId} not found`); + } + + for (const objectMetadata of objectMetadataCollection) { + // Check object metadata health + const objectIssues = await this.objectMetadataHealthService.healthCheck( + schemaName, + objectMetadata, + options, + ); + + issues.push(...objectIssues); + + // Check fields metadata health + const fieldIssues = await this.fieldMetadataHealthService.healthCheck( + schemaName, + objectMetadata.targetTableName, + objectMetadata.fields, + options, + ); + + issues.push(...fieldIssues); + } + + return issues; + } +}