diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 4fa846753..a8a22022a 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -11,9 +11,9 @@ import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/ti import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { HealthModule } from 'src/engine/core-modules/health/health.module'; -import { AnalyticsModule } from './analytics/analytics.module'; -import { FileModule } from './file/file.module'; import { ClientConfigModule } from './client-config/client-config.module'; +import { FileModule } from './file/file.module'; +import { AnalyticsModule } from './analytics/analytics.module'; @Module({ imports: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts index 970ac9baa..35b3c740b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts @@ -1,11 +1,23 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface'; -import { currencyCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; -import { fullNameCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; -import { linkCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type'; +import { + CurrencyMetadata, + currencyCompositeType, +} from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; +import { + FullNameMetadata, + fullNameCompositeType, +} from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; +import { + LinkMetadata, + linkCompositeType, +} from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { addressCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type'; +import { + AddressMetadata, + addressCompositeType, +} from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type'; export type CompositeFieldsDefinitionFunction = ( fieldMetadata?: FieldMetadataInterface, @@ -20,3 +32,9 @@ export const compositeTypeDefintions = new Map< [FieldMetadataType.FULL_NAME, fullNameCompositeType], [FieldMetadataType.ADDRESS, addressCompositeType], ]); + +export type CompositeMetadataTypes = + | AddressMetadata + | CurrencyMetadata + | FullNameMetadata + | LinkMetadata; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts index 22498783e..ee790edee 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts @@ -503,7 +503,10 @@ export class FieldMetadataService extends TypeOrmQueryService( return generateName(fieldMetadataOrFieldName.name); } - -export const computeCompositeColumnName = < +export function computeCompositeColumnName( + fieldName: string, + compositeProperty: CompositeProperty, +): string; +export function computeCompositeColumnName< T extends FieldMetadataType | 'default', >( fieldMetadata: FieldMetadataInterface, compositeProperty: CompositeProperty, -): string => { - if (!isCompositeFieldMetadataType(fieldMetadata.type)) { +): string; +export function computeCompositeColumnName< + T extends FieldMetadataType | 'default', +>( + fieldMetadataOrFieldName: FieldMetadataInterface | string, + compositeProperty: CompositeProperty, +): string { + const generateName = (name: string) => { + return `${name}${pascalCase(compositeProperty.name)}`; + }; + + if (typeof fieldMetadataOrFieldName === 'string') { + return generateName(fieldMetadataOrFieldName); + } + + if (!isCompositeFieldMetadataType(fieldMetadataOrFieldName.type)) { throw new Error( - `Cannot compute composite column name for non-composite field metadata type: ${fieldMetadata.type}`, + `Cannot compute composite column name for non-composite field metadata type: ${fieldMetadataOrFieldName.type}`, ); } - return `${fieldMetadata.name}${pascalCase(compositeProperty.name)}`; -}; + return `${fieldMetadataOrFieldName.name}${pascalCase( + compositeProperty.name, + )}`; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.entity.ts index 25f0ad6f9..fe0ffe86e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.entity.ts @@ -18,6 +18,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat export enum RelationMetadataType { ONE_TO_ONE = 'ONE_TO_ONE', ONE_TO_MANY = 'ONE_TO_MANY', + MANY_TO_ONE = 'MANY_TO_ONE', MANY_TO_MANY = 'MANY_TO_MANY', } diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-datasource.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-datasource.decorator.ts new file mode 100644 index 000000000..f9bae643f --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-datasource.decorator.ts @@ -0,0 +1,6 @@ +import { Inject } from '@nestjs/common'; + +import { TWENTY_ORM_WORKSPACE_DATASOURCE } from 'src/engine/twenty-orm/twenty-orm.constants'; + +export const InjectWorkspaceDatasource = () => + Inject(TWENTY_ORM_WORKSPACE_DATASOURCE); diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-repository.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-repository.decorator.ts new file mode 100644 index 000000000..4c2047a06 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-repository.decorator.ts @@ -0,0 +1,8 @@ +import { Inject } from '@nestjs/common'; +import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; + +import { getWorkspaceRepositoryToken } from 'src/engine/twenty-orm/utils/get-workspace-repository-token.util'; + +export const InjectWorkspaceRepository = ( + entity: EntityClassOrSchema, +): ReturnType => Inject(getWorkspaceRepositoryToken(entity)); diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts new file mode 100644 index 000000000..1edc28565 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts @@ -0,0 +1,63 @@ +import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; +import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface'; + +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +export interface WorkspaceFieldOptions< + T extends FieldMetadataType | 'default', +> { + standardId: string; + type: T; + label: string | ((objectMetadata: ObjectMetadataEntity) => string); + description?: string | ((objectMetadata: ObjectMetadataEntity) => string); + icon?: string; + defaultValue?: FieldMetadataDefaultValue; + joinColumn?: string; + options?: FieldMetadataOptions; +} + +export function WorkspaceField( + options: WorkspaceFieldOptions, +): PropertyDecorator { + return (object, propertyKey) => { + const isPrimary = TypedReflect.getMetadata( + 'workspace:is-primary-field-metadata-args', + object, + propertyKey.toString(), + ); + const isNullable = TypedReflect.getMetadata( + 'workspace:is-nullable-metadata-args', + object, + propertyKey.toString(), + ); + const isSystem = TypedReflect.getMetadata( + 'workspace:is-system-metadata-args', + object, + propertyKey.toString(), + ); + const gate = TypedReflect.getMetadata( + 'workspace:gate-metadata-args', + object, + propertyKey.toString(), + ); + + metadataArgsStorage.fields.push({ + target: object.constructor, + standardId: options.standardId, + name: propertyKey.toString(), + label: options.label, + type: options.type, + description: options.description, + icon: options.icon, + defaultValue: options.defaultValue, + options: options.options, + isPrimary, + isNullable, + isSystem, + gate, + }); + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts new file mode 100644 index 000000000..ed63b20ee --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts @@ -0,0 +1,24 @@ +import { TypedReflect } from 'src/utils/typed-reflect'; + +export interface WorkspaceGateOptions { + featureFlag: string; +} + +export function WorkspaceGate(options: WorkspaceGateOptions) { + return (target: any, propertyKey?: string | symbol) => { + if (propertyKey !== undefined) { + TypedReflect.defineMetadata( + 'workspace:gate-metadata-args', + options, + target, + propertyKey.toString(), + ); + } else { + TypedReflect.defineMetadata( + 'workspace:gate-metadata-args', + options, + target, + ); + } + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator.ts new file mode 100644 index 000000000..dd7d2af25 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator.ts @@ -0,0 +1,11 @@ +import { TypedReflect } from 'src/utils/typed-reflect'; + +export function WorkspaceIsNotAuditLogged(): ClassDecorator { + return (object) => { + TypedReflect.defineMetadata( + 'workspace:is-audit-logged-metadata-args', + false, + object, + ); + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-nullable.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-nullable.decorator.ts new file mode 100644 index 000000000..980126ffc --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-nullable.decorator.ts @@ -0,0 +1,12 @@ +import { TypedReflect } from 'src/utils/typed-reflect'; + +export function WorkspaceIsNullable(): PropertyDecorator { + return (object, propertyKey) => { + TypedReflect.defineMetadata( + 'workspace:is-nullable-metadata-args', + true, + object, + propertyKey.toString(), + ); + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-primary-field.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-primary-field.decorator.ts new file mode 100644 index 000000000..5dc8a389b --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-primary-field.decorator.ts @@ -0,0 +1,12 @@ +import { TypedReflect } from 'src/utils/typed-reflect'; + +export function WorkspaceIsPimaryField(): PropertyDecorator { + return (object, propertyKey) => { + TypedReflect.defineMetadata( + 'workspace:is-primary-field-metadata-args', + true, + object, + propertyKey.toString(), + ); + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-system.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-system.decorator.ts new file mode 100644 index 000000000..3be9a1343 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-system.decorator.ts @@ -0,0 +1,20 @@ +import { TypedReflect } from 'src/utils/typed-reflect'; + +export function WorkspaceIsSystem() { + return function (target: any, propertyKey?: string | symbol): void { + if (propertyKey !== undefined) { + TypedReflect.defineMetadata( + 'workspace:is-system-metadata-args', + true, + target, + propertyKey.toString(), + ); + } else { + TypedReflect.defineMetadata( + 'workspace:is-system-metadata-args', + true, + target, + ); + } + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-object.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-object.decorator.ts new file mode 100644 index 000000000..e57057f88 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-object.decorator.ts @@ -0,0 +1,47 @@ +import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; +import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +interface WorkspaceObjectOptions { + standardId: string; + namePlural: string; + labelSingular: string; + labelPlural: string; + description?: string; + icon?: string; +} + +export function WorkspaceObject( + options: WorkspaceObjectOptions, +): ClassDecorator { + return (target) => { + const isAuditLogged = + TypedReflect.getMetadata( + 'workspace:is-audit-logged-metadata-args', + target, + ) ?? true; + const isSystem = TypedReflect.getMetadata( + 'workspace:is-system-metadata-args', + target, + ); + const gate = TypedReflect.getMetadata( + 'workspace:gate-metadata-args', + target, + ); + const objectName = convertClassNameToObjectMetadataName(target.name); + + metadataArgsStorage.objects.push({ + target, + standardId: options.standardId, + nameSingular: objectName, + namePlural: options.namePlural, + labelSingular: options.labelSingular, + labelPlural: options.labelPlural, + description: options.description, + icon: options.icon, + isAuditLogged, + isSystem, + gate, + }); + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts new file mode 100644 index 000000000..d583df0df --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts @@ -0,0 +1,88 @@ +import { ObjectType } from 'typeorm'; + +import { + RelationMetadataType, + RelationOnDeleteAction, +} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +interface WorkspaceBaseRelationOptions { + standardId: string; + label: string; + description?: string; + icon?: string; + type: TType; + inverseSideTarget: () => ObjectType; + inverseSideFieldKey?: keyof TClass; + onDelete?: RelationOnDeleteAction; +} + +export interface WorkspaceManyToOneRelationOptions + extends WorkspaceBaseRelationOptions< + RelationMetadataType.MANY_TO_ONE | RelationMetadataType.ONE_TO_ONE, + TClass + > { + joinColumn?: string; +} + +export interface WorkspaceOtherRelationOptions + extends WorkspaceBaseRelationOptions< + RelationMetadataType.ONE_TO_MANY | RelationMetadataType.MANY_TO_MANY, + TClass + > {} + +export function WorkspaceRelation( + options: + | WorkspaceManyToOneRelationOptions + | WorkspaceOtherRelationOptions, +): PropertyDecorator { + return (object, propertyKey) => { + const isPrimary = TypedReflect.getMetadata( + 'workspace:is-primary-field-metadata-args', + object, + propertyKey.toString(), + ); + const isNullable = TypedReflect.getMetadata( + 'workspace:is-nullable-metadata-args', + object, + propertyKey.toString(), + ); + const isSystem = TypedReflect.getMetadata( + 'workspace:is-system-metadata-args', + object, + propertyKey.toString(), + ); + const gate = TypedReflect.getMetadata( + 'workspace:gate-metadata-args', + object, + propertyKey.toString(), + ); + + let joinColumn: string | undefined; + + if ('joinColumn' in options) { + joinColumn = options.joinColumn + ? options.joinColumn + : `${propertyKey.toString()}Id`; + } + + metadataArgsStorage.relations.push({ + target: object.constructor, + standardId: options.standardId, + name: propertyKey.toString(), + label: options.label, + type: options.type, + description: options.description, + icon: options.icon, + inverseSideTarget: options.inverseSideTarget, + inverseSideFieldKey: options.inverseSideFieldKey as string | undefined, + onDelete: options.onDelete, + joinColumn, + isPrimary, + isNullable, + isSystem, + gate, + }); + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts new file mode 100644 index 000000000..c5fd7b8ac --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@nestjs/common'; + +import { ColumnType, EntitySchemaColumnOptions } from 'typeorm'; + +import { WorkspaceFieldMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface'; + +import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util'; +import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; +import { serializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-default-value'; +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { compositeTypeDefintions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +type EntitySchemaColumnMap = { + [key: string]: EntitySchemaColumnOptions; +}; + +@Injectable() +export class EntitySchemaColumnFactory { + create( + fieldMetadataArgsCollection: WorkspaceFieldMetadataArgs[], + ): EntitySchemaColumnMap { + let entitySchemaColumnMap: EntitySchemaColumnMap = {}; + + for (const fieldMetadataArgs of fieldMetadataArgsCollection) { + const key = fieldMetadataArgs.name; + + if (isCompositeFieldMetadataType(fieldMetadataArgs.type)) { + const compositeColumns = this.createCompositeColumns(fieldMetadataArgs); + + entitySchemaColumnMap = { + ...entitySchemaColumnMap, + ...compositeColumns, + }; + + continue; + } + + const columnType = fieldMetadataTypeToColumnType(fieldMetadataArgs.type); + const defaultValue = serializeDefaultValue( + fieldMetadataArgs.defaultValue, + ); + + entitySchemaColumnMap[key] = { + name: key, + type: columnType as ColumnType, + primary: fieldMetadataArgs.isPrimary, + nullable: fieldMetadataArgs.isNullable, + createDate: key === 'createdAt', + updateDate: key === 'updatedAt', + array: fieldMetadataArgs.type === FieldMetadataType.MULTI_SELECT, + default: defaultValue, + }; + + if (isEnumFieldMetadataType(fieldMetadataArgs.type)) { + const values = fieldMetadataArgs.options?.map((option) => option.value); + + if (values && values.length > 0) { + entitySchemaColumnMap[key].enum = values; + } + } + } + + return entitySchemaColumnMap; + } + + private createCompositeColumns( + fieldMetadataArgs: WorkspaceFieldMetadataArgs, + ): EntitySchemaColumnMap { + const entitySchemaColumnMap: EntitySchemaColumnMap = {}; + const compositeType = compositeTypeDefintions.get(fieldMetadataArgs.type); + + if (!compositeType) { + throw new Error( + `Composite type ${fieldMetadataArgs.type} is not defined in compositeTypeDefintions`, + ); + } + + for (const compositeProperty of compositeType.properties) { + const columnName = computeCompositeColumnName( + fieldMetadataArgs.name, + compositeProperty, + ); + const columnType = fieldMetadataTypeToColumnType(compositeProperty.type); + const defaultValue = serializeDefaultValue( + fieldMetadataArgs.defaultValue?.[compositeProperty.name], + ); + + entitySchemaColumnMap[columnName] = { + name: columnName, + type: columnType as ColumnType, + nullable: compositeProperty.isRequired, + default: defaultValue, + }; + + if (isEnumFieldMetadataType(compositeProperty.type)) { + throw new Error('Enum composite properties are not yet supported'); + } + } + + return entitySchemaColumnMap; + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts new file mode 100644 index 000000000..831624418 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; + +import { EntitySchemaRelationOptions } from 'typeorm'; +import { RelationType } from 'typeorm/metadata/types/RelationTypes'; + +import { WorkspaceRelationMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface'; + +import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util'; +import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; + +type EntitySchemaRelationMap = { + [key: string]: EntitySchemaRelationOptions; +}; + +@Injectable() +export class EntitySchemaRelationFactory { + create( + relationMetadataArgsCollection: WorkspaceRelationMetadataArgs[], + ): EntitySchemaRelationMap { + const entitySchemaRelationMap: EntitySchemaRelationMap = {}; + + for (const relationMetadataArgs of relationMetadataArgsCollection) { + const oppositeTarget = relationMetadataArgs.inverseSideTarget(); + const oppositeObjectName = convertClassNameToObjectMetadataName( + oppositeTarget.name, + ); + + const relationType = this.getRelationType(relationMetadataArgs); + + entitySchemaRelationMap[relationMetadataArgs.name] = { + type: relationType, + target: oppositeObjectName, + inverseSide: relationMetadataArgs.inverseSideFieldKey, + joinColumn: relationMetadataArgs.joinColumn + ? { + name: relationMetadataArgs.joinColumn, + } + : undefined, + }; + } + + return entitySchemaRelationMap; + } + + private getRelationType( + relationMetadataArgs: WorkspaceRelationMetadataArgs, + ): RelationType { + switch (relationMetadataArgs.type) { + case RelationMetadataType.ONE_TO_MANY: + return 'one-to-many'; + case RelationMetadataType.MANY_TO_ONE: + return 'many-to-one'; + case RelationMetadataType.ONE_TO_ONE: + return 'one-to-one'; + case RelationMetadataType.MANY_TO_MANY: + return 'many-to-many'; + default: + throw new Error('Invalid relation type'); + } + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema.factory.ts new file mode 100644 index 000000000..0bfdc985d --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema.factory.ts @@ -0,0 +1,43 @@ +import { Injectable, Type } from '@nestjs/common'; + +import { EntitySchema } from 'typeorm'; + +import { EntitySchemaColumnFactory } from 'src/engine/twenty-orm/factories/entity-schema-column.factory'; +import { EntitySchemaRelationFactory } from 'src/engine/twenty-orm/factories/entity-schema-relation.factory'; +import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; + +@Injectable() +export class EntitySchemaFactory { + constructor( + private readonly entitySchemaColumnFactory: EntitySchemaColumnFactory, + private readonly entitySchemaRelationFactory: EntitySchemaRelationFactory, + ) {} + + create(target: Type): EntitySchema { + const objectMetadataArgs = metadataArgsStorage.filterObjects(target); + + if (!objectMetadataArgs) { + throw new Error('Object metadata args are missing on this target'); + } + + const fieldMetadataArgsCollection = + metadataArgsStorage.filterFields(target); + const relationMetadataArgsCollection = + metadataArgsStorage.filterRelations(target); + + const columns = this.entitySchemaColumnFactory.create( + fieldMetadataArgsCollection, + ); + + const relations = this.entitySchemaRelationFactory.create( + relationMetadataArgsCollection, + ); + + return new EntitySchema({ + name: objectMetadataArgs.nameSingular, + tableName: objectMetadataArgs.nameSingular, + columns, + relations, + }); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/index.ts b/packages/twenty-server/src/engine/twenty-orm/factories/index.ts new file mode 100644 index 000000000..b07da0d7b --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/factories/index.ts @@ -0,0 +1,11 @@ +import { EntitySchemaColumnFactory } from 'src/engine/twenty-orm/factories/entity-schema-column.factory'; +import { EntitySchemaRelationFactory } from 'src/engine/twenty-orm/factories/entity-schema-relation.factory'; +import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory'; +import { WorkspaceDatasourceFactory } from 'src/engine/twenty-orm/factories/workspace-datasource.factory'; + +export const entitySchemaFactories = [ + EntitySchemaColumnFactory, + EntitySchemaRelationFactory, + EntitySchemaFactory, + WorkspaceDatasourceFactory, +]; diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts new file mode 100644 index 000000000..1094e38cc --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; + +import { DataSource, EntitySchema } from 'typeorm'; + +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceStorage } from 'src/engine/twenty-orm/storage/data-source.storage'; + +@Injectable({ scope: Scope.REQUEST }) +export class WorkspaceDatasourceFactory { + constructor( + @Inject(REQUEST) private readonly request: Request, + private readonly dataSourceService: DataSourceService, + private readonly environmentService: EnvironmentService, + ) {} + + public async createWorkspaceDatasource(entities: EntitySchema[]) { + const workspace: Workspace = this.request['req']['workspace']; + + if (!workspace) { + return null; + } + + const storedWorkspaceDataSource = DataSourceStorage.getDataSource( + workspace.id, + ); + + if (storedWorkspaceDataSource) { + return storedWorkspaceDataSource; + } + + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspace.id, + ); + + const workspaceDataSource = new DataSource({ + url: + dataSourceMetadata.url ?? + this.environmentService.get('PG_DATABASE_URL'), + type: 'postgres', + // logging: this.environmentService.get('DEBUG_MODE') + // ? ['query', 'error'] + // : ['error'], + logging: 'all', + schema: dataSourceMetadata.schema, + entities, + }); + + await workspaceDataSource.initialize(); + + DataSourceStorage.setDataSource(workspace.id, workspaceDataSource); + + return workspaceDataSource; + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/flatten-composite-types.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/flatten-composite-types.interface.ts new file mode 100644 index 000000000..5d549c7ec --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/flatten-composite-types.interface.ts @@ -0,0 +1,9 @@ +import { CompositeMetadataTypes } from 'src/engine/metadata-modules/field-metadata/composite-types'; + +// TODO: At the time the composite types are generating union of types instead of a single type for their keys +// We need to find a way to fix that +export type FlattenCompositeTypes = { + [P in keyof T as T[P] extends CompositeMetadataTypes + ? `${string & P}${Capitalize}` + : P]: T[P] extends CompositeMetadataTypes ? T[P][keyof T[P]] : T[P]; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts new file mode 100644 index 000000000..e88d9fd0a --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts @@ -0,0 +1,3 @@ +export interface Gate { + featureFlag: string; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/twenty-orm-options.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/twenty-orm-options.interface.ts new file mode 100644 index 000000000..e9b2ab224 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/twenty-orm-options.interface.ts @@ -0,0 +1,12 @@ +import { FactoryProvider, ModuleMetadata, Type } from '@nestjs/common'; + +import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata'; + +export interface TwentyORMOptions { + objects: Type[]; +} + +export type TwentyORMModuleAsyncOptions = { + useFactory: (...args: any[]) => TwentyORMOptions | Promise; +} & Pick & + Pick; diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts new file mode 100644 index 000000000..08b8d909d --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts @@ -0,0 +1,76 @@ +import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; +import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface'; +import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; + +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; + +export interface WorkspaceFieldMetadataArgs { + /** + * Standard id. + */ + readonly standardId: string; + + /** + * Class to which field is applied. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + readonly target: Function | string; + + /** + * Field name. + */ + readonly name: string; + + /** + * Field label. + */ + readonly label: string | ((objectMetadata: ObjectMetadataEntity) => string); + + /** + * Field type. + */ + readonly type: FieldMetadataType; + + /** + * Field description. + */ + readonly description?: + | string + | ((objectMetadata: ObjectMetadataEntity) => string); + + /** + * Field icon. + */ + readonly icon?: string; + + /** + * Field default value. + */ + readonly defaultValue?: FieldMetadataDefaultValue; + + /** + * Field options. + */ + readonly options?: FieldMetadataOptions; + + /** + * Is primary field. + */ + readonly isPrimary?: boolean; + + /** + * Is system field. + */ + readonly isSystem?: boolean; + + /** + * Is nullable field. + */ + readonly isNullable?: boolean; + + /** + * Field gate. + */ + readonly gate?: Gate; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-object-metadata-args.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-object-metadata-args.interface.ts new file mode 100644 index 000000000..b7cf899b9 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-object-metadata-args.interface.ts @@ -0,0 +1,53 @@ +import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; + +export interface WorkspaceObjectMetadataArgs { + /** + * Standard id. + */ + readonly standardId: string; + + /** + * Class to which table is applied. + * Function target is a table defined in the class. + * String target is a table defined in a json schema. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + readonly target: Function | string; + + /** + * Object name. + */ + readonly nameSingular: string; + readonly namePlural: string; + + /** + * Object label. + */ + readonly labelSingular: string; + readonly labelPlural: string; + + /** + * Object description. + */ + readonly description?: string; + + /** + * Object icon. + */ + readonly icon?: string; + + /** + * Is audit logged. + */ + readonly isAuditLogged: boolean; + + /** + * Is system object. + */ + readonly isSystem?: boolean; + + /** + * Object gate. + */ + readonly gate?: Gate; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface.ts new file mode 100644 index 000000000..5afe81883 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface.ts @@ -0,0 +1,89 @@ +import { ObjectType } from 'typeorm'; + +import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; + +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { + RelationMetadataType, + RelationOnDeleteAction, +} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; + +export interface WorkspaceRelationMetadataArgs { + /** + * Standard id. + */ + readonly standardId: string; + + /** + * Class to which relation is applied. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + readonly target: Function | string; + + /** + * Relation name. + */ + readonly name: string; + + /** + * Relation label. + */ + readonly label: string | ((objectMetadata: ObjectMetadataEntity) => string); + + /** + * Relation type. + */ + readonly type: RelationMetadataType; + + /** + * Relation description. + */ + readonly description?: + | string + | ((objectMetadata: ObjectMetadataEntity) => string); + + /** + * Relation icon. + */ + readonly icon?: string; + + /** + * Relation inverse side target. + */ + readonly inverseSideTarget: () => ObjectType; + + /** + * Relation inverse side field key. + */ + readonly inverseSideFieldKey?: string; + + /** + * Relation on delete action. + */ + readonly onDelete?: RelationOnDeleteAction; + + /** + * Relation join column. + */ + readonly joinColumn?: string; + + /** + * Is primary field. + */ + readonly isPrimary?: boolean; + + /** + * Is system field. + */ + readonly isSystem?: boolean; + + /** + * Is nullable field. + */ + readonly isNullable?: boolean; + + /** + * Field gate. + */ + readonly gate?: Gate; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts new file mode 100644 index 000000000..5731b808a --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -0,0 +1,7 @@ +import { ObjectLiteral, Repository } from 'typeorm'; + +import { FlattenCompositeTypes } from 'src/engine/twenty-orm/interfaces/flatten-composite-types.interface'; + +export class WorkspaceRepository< + Entity extends ObjectLiteral, +> extends Repository> {} diff --git a/packages/twenty-server/src/engine/twenty-orm/storage/data-source.storage.ts b/packages/twenty-server/src/engine/twenty-orm/storage/data-source.storage.ts new file mode 100644 index 000000000..e09b9bdbf --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/storage/data-source.storage.ts @@ -0,0 +1,17 @@ +import { DataSource } from 'typeorm'; + +export class DataSourceStorage { + private static readonly dataSources: Map = new Map(); + + public static getDataSource(key: string): DataSource | undefined { + return this.dataSources.get(key); + } + + public static setDataSource(key: string, dataSource: DataSource): void { + this.dataSources.set(key, dataSource); + } + + public static getAllDataSources(): DataSource[] { + return Array.from(this.dataSources.values()); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/storage/metadata-args.storage.ts b/packages/twenty-server/src/engine/twenty-orm/storage/metadata-args.storage.ts new file mode 100644 index 000000000..f54da7ea4 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/storage/metadata-args.storage.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/ban-types */ + +import { WorkspaceFieldMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface'; +import { WorkspaceObjectMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-object-metadata-args.interface'; +import { WorkspaceRelationMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface'; + +export class MetadataArgsStorage { + readonly objects: WorkspaceObjectMetadataArgs[] = []; + readonly fields: WorkspaceFieldMetadataArgs[] = []; + readonly relations: WorkspaceRelationMetadataArgs[] = []; + + filterObjects( + target: Function | string, + ): WorkspaceObjectMetadataArgs | undefined; + + filterObjects(target: (Function | string)[]): WorkspaceObjectMetadataArgs[]; + + filterObjects( + target: (Function | string) | (Function | string)[], + ): WorkspaceObjectMetadataArgs | undefined | WorkspaceObjectMetadataArgs[] { + const objects = this.filterByTarget(this.objects, target); + + return Array.isArray(objects) ? objects[0] : objects; + } + + filterFields(target: Function | string): WorkspaceFieldMetadataArgs[]; + + filterFields(target: (Function | string)[]): WorkspaceFieldMetadataArgs[]; + + filterFields( + target: (Function | string) | (Function | string)[], + ): WorkspaceFieldMetadataArgs[] { + return this.filterByTarget(this.fields, target); + } + + filterRelations(target: Function | string): WorkspaceRelationMetadataArgs[]; + + filterRelations( + target: (Function | string)[], + ): WorkspaceRelationMetadataArgs[]; + + filterRelations( + target: (Function | string) | (Function | string)[], + ): WorkspaceRelationMetadataArgs[] { + return this.filterByTarget(this.relations, target); + } + + protected filterByTarget( + array: T[], + target: (Function | string) | (Function | string)[], + ): T[] { + if (Array.isArray(target)) { + return target.flatMap((targetItem) => { + if (typeof targetItem === 'function') { + return this.collectFromClass(array, targetItem); + } + + return this.collectFromString(array, targetItem); + }); + } else { + return typeof target === 'function' + ? this.collectFromClass(array, target) + : this.collectFromString(array, target); + } + } + + // Private helper to collect metadata from class prototypes + private collectFromClass( + array: T[], + cls: Function, + ): T[] { + const collectedMetadata: T[] = []; + let currentTarget = cls; + + // Collect metadata from the current class and all its parent classes + while (currentTarget !== Function.prototype) { + collectedMetadata.push( + ...array.filter((item) => item.target === currentTarget), + ); + currentTarget = Object.getPrototypeOf(currentTarget); + } + + return collectedMetadata; + } + + // Private helper to collect metadata directly by string comparison + private collectFromString( + array: T[], + targetString: string, + ): T[] { + return array.filter((item) => item.target === targetString); + } +} + +export const metadataArgsStorage = new MetadataArgsStorage(); diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm-core.module.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm-core.module.ts new file mode 100644 index 000000000..2f73b260d --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm-core.module.ts @@ -0,0 +1,130 @@ +import { + DynamicModule, + Global, + Logger, + Module, + OnApplicationShutdown, + Provider, +} from '@nestjs/common'; +import { + ConfigurableModuleClass, + MODULE_OPTIONS_TOKEN, +} from '@nestjs/common/cache/cache.module-definition'; + +import { + TwentyORMModuleAsyncOptions, + TwentyORMOptions, +} from 'src/engine/twenty-orm/interfaces/twenty-orm-options.interface'; + +import { entitySchemaFactories } from 'src/engine/twenty-orm/factories'; +import { TWENTY_ORM_WORKSPACE_DATASOURCE } from 'src/engine/twenty-orm/twenty-orm.constants'; +import { TwentyORMService } from 'src/engine/twenty-orm/twenty-orm.service'; +import { WorkspaceDatasourceFactory } from 'src/engine/twenty-orm/factories/workspace-datasource.factory'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory'; +import { DataSourceStorage } from 'src/engine/twenty-orm/storage/data-source.storage'; + +@Global() +@Module({ + imports: [DataSourceModule], + providers: [...entitySchemaFactories, TwentyORMService], + exports: [EntitySchemaFactory, TwentyORMService], +}) +export class TwentyORMCoreModule + extends ConfigurableModuleClass + implements OnApplicationShutdown +{ + private readonly logger = new Logger(TwentyORMCoreModule.name); + + static register(options: TwentyORMOptions): DynamicModule { + const dynamicModule = super.register(options); + const providers: Provider[] = [ + { + provide: TWENTY_ORM_WORKSPACE_DATASOURCE, + useFactory: async ( + entitySchemaFactory: EntitySchemaFactory, + workspaceDatasourceFactory: WorkspaceDatasourceFactory, + ) => { + const entities = options.objects.map((entityClass) => + entitySchemaFactory.create(entityClass), + ); + + const dataSource = + await workspaceDatasourceFactory.createWorkspaceDatasource( + entities, + ); + + return dataSource; + }, + inject: [EntitySchemaFactory, WorkspaceDatasourceFactory], + }, + ]; + + return { + ...dynamicModule, + providers: [...(dynamicModule.providers ?? []), ...providers], + exports: [ + ...(dynamicModule.exports ?? []), + TWENTY_ORM_WORKSPACE_DATASOURCE, + ], + }; + } + + static registerAsync( + asyncOptions: TwentyORMModuleAsyncOptions, + ): DynamicModule { + const dynamicModule = super.registerAsync(asyncOptions); + const providers: Provider[] = [ + { + provide: TWENTY_ORM_WORKSPACE_DATASOURCE, + useFactory: async ( + entitySchemaFactory: EntitySchemaFactory, + workspaceDatasourceFactory: WorkspaceDatasourceFactory, + options: TwentyORMOptions, + ) => { + const entities = options.objects.map((entityClass) => + entitySchemaFactory.create(entityClass), + ); + + const dataSource = + await workspaceDatasourceFactory.createWorkspaceDatasource( + entities, + ); + + return dataSource; + }, + inject: [ + EntitySchemaFactory, + WorkspaceDatasourceFactory, + MODULE_OPTIONS_TOKEN, + ], + }, + ]; + + return { + ...dynamicModule, + providers: [...(dynamicModule.providers ?? []), ...providers], + exports: [ + ...(dynamicModule.exports ?? []), + TWENTY_ORM_WORKSPACE_DATASOURCE, + ], + }; + } + + /** + * Destroys all data sources on application shutdown + */ + async onApplicationShutdown() { + const dataSources = DataSourceStorage.getAllDataSources(); + + for (const dataSource of dataSources) { + try { + if (dataSource && dataSource.isInitialized) { + await dataSource.destroy(); + } + } catch (e) { + this.logger.error(e?.message); + } + } + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.constants.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.constants.ts new file mode 100644 index 000000000..749829090 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.constants.ts @@ -0,0 +1,2 @@ +export const TWENTY_ORM_WORKSPACE_DATASOURCE = + 'TWENTY_ORM_WORKSPACE_DATASOURCE'; diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module-definition.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module-definition.ts new file mode 100644 index 000000000..bb7d51c44 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module-definition.ts @@ -0,0 +1,10 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; + +import { TwentyORMOptions } from './interfaces/twenty-orm-options.interface'; + +export const { + ConfigurableModuleClass, + MODULE_OPTIONS_TOKEN, + OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder().build(); diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts new file mode 100644 index 000000000..3cf38e38c --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts @@ -0,0 +1,41 @@ +import { DynamicModule, Global, Module } from '@nestjs/common'; +import { ConfigurableModuleClass } from '@nestjs/common/cache/cache.module-definition'; +import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; + +import { + TwentyORMModuleAsyncOptions, + TwentyORMOptions, +} from 'src/engine/twenty-orm/interfaces/twenty-orm-options.interface'; + +import { createTwentyORMProviders } from 'src/engine/twenty-orm/twenty-orm.providers'; +import { TwentyORMCoreModule } from 'src/engine/twenty-orm/twenty-orm-core.module'; + +@Global() +@Module({}) +export class TwentyORMModule extends ConfigurableModuleClass { + static register(options: TwentyORMOptions): DynamicModule { + return { + module: TwentyORMModule, + imports: [TwentyORMCoreModule.register(options)], + }; + } + + static forFeature(objects: EntityClassOrSchema[] = []): DynamicModule { + const providers = createTwentyORMProviders(objects); + + return { + module: TwentyORMModule, + providers: providers, + exports: providers, + }; + } + + static registerAsync( + asyncOptions: TwentyORMModuleAsyncOptions, + ): DynamicModule { + return { + module: TwentyORMModule, + imports: [TwentyORMCoreModule.registerAsync(asyncOptions)], + }; + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.providers.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.providers.ts new file mode 100644 index 000000000..22621e362 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.providers.ts @@ -0,0 +1,28 @@ +import { Provider, Type } from '@nestjs/common'; +import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; + +import { DataSource } from 'typeorm'; + +import { getWorkspaceRepositoryToken } from 'src/engine/twenty-orm/utils/get-workspace-repository-token.util'; +import { TWENTY_ORM_WORKSPACE_DATASOURCE } from 'src/engine/twenty-orm/twenty-orm.constants'; +import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory'; + +/** + * Create providers for the given entities. + */ +export function createTwentyORMProviders( + objects?: EntityClassOrSchema[], +): Provider[] { + return (objects || []).map((object) => ({ + provide: getWorkspaceRepositoryToken(object), + useFactory: ( + dataSource: DataSource, + entitySchemaFactory: EntitySchemaFactory, + ) => { + const entity = entitySchemaFactory.create(object as Type); + + return dataSource.getRepository(entity); + }, + inject: [TWENTY_ORM_WORKSPACE_DATASOURCE, EntitySchemaFactory], + })); +} diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.service.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.service.ts new file mode 100644 index 000000000..b77ac8c63 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.service.ts @@ -0,0 +1,27 @@ +import { Injectable, Type } from '@nestjs/common'; + +import { DataSource, ObjectLiteral, Repository } from 'typeorm'; + +import { FlattenCompositeTypes } from 'src/engine/twenty-orm/interfaces/flatten-composite-types.interface'; + +import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory'; +import { InjectWorkspaceDatasource } from 'src/engine/twenty-orm/decorators/inject-workspace-datasource.decorator'; + +@Injectable() +export class TwentyORMService { + constructor( + @InjectWorkspaceDatasource() + private readonly workspaceDataSource: DataSource, + private readonly entitySchemaFactory: EntitySchemaFactory, + ) {} + + getRepository( + entityClass: Type, + ): Repository> { + const entitySchema = this.entitySchemaFactory.create(entityClass); + + return this.workspaceDataSource.getRepository>( + entitySchema, + ); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-workspace-repository-token.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-workspace-repository-token.util.ts new file mode 100644 index 000000000..ad9a61537 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-workspace-repository-token.util.ts @@ -0,0 +1,24 @@ +import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; + +import { EntitySchema, Repository } from 'typeorm'; + +export function getWorkspaceRepositoryToken( + entity: EntityClassOrSchema, + // eslint-disable-next-line @typescript-eslint/ban-types +): Function | string { + if (entity === null || entity === undefined) { + throw new Error('Circular dependency @InjectWorkspaceRepository()'); + } + + if (entity instanceof Function && entity.prototype instanceof Repository) { + return entity; + } + + if (entity instanceof EntitySchema) { + return `${ + entity.options.target ? entity.options.target.name : entity.options.name + }WorkspaceRepository`; + } + + return `${entity.name}WorkspaceRepository`; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/attachment.object-metadata.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/attachment.object-metadata.ts new file mode 100644 index 000000000..cbe747445 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/attachment.object-metadata.ts @@ -0,0 +1,61 @@ +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ATTACHMENT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { WorkspaceObject } from 'src/engine/twenty-orm/decorators/workspace-object.decorator'; +import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; +import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { BaseObjectMetadata } from 'src/engine/twenty-orm/workspace-object-tests/base.object-metadata'; +import { WorkspaceMemberObjectMetadata } from 'src/engine/twenty-orm/workspace-object-tests/workspace-member.object-metadata'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; +import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; + +@WorkspaceObject({ + standardId: STANDARD_OBJECT_IDS.attachment, + namePlural: 'attachments', + labelSingular: 'Attachment', + labelPlural: 'Attachments', + description: 'An attachment', + icon: 'IconFileImport', +}) +@WorkspaceIsSystem() +@WorkspaceIsNotAuditLogged() +export class AttachmentObjectMetadata extends BaseObjectMetadata { + @WorkspaceField({ + standardId: ATTACHMENT_STANDARD_FIELD_IDS.name, + type: FieldMetadataType.TEXT, + label: 'Name', + description: 'Attachment name', + icon: 'IconFileUpload', + }) + name: string; + + @WorkspaceField({ + standardId: ATTACHMENT_STANDARD_FIELD_IDS.fullPath, + type: FieldMetadataType.TEXT, + label: 'Full path', + description: 'Attachment full path', + icon: 'IconLink', + }) + fullPath: string; + + @WorkspaceField({ + standardId: ATTACHMENT_STANDARD_FIELD_IDS.type, + type: FieldMetadataType.TEXT, + label: 'Type', + description: 'Attachment type', + icon: 'IconList', + }) + type: string; + + @WorkspaceRelation({ + standardId: ATTACHMENT_STANDARD_FIELD_IDS.author, + label: 'Author', + type: RelationMetadataType.MANY_TO_ONE, + inverseSideTarget: () => WorkspaceMemberObjectMetadata, + inverseSideFieldKey: 'authoredAttachments', + }) + author: Relation; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/base.object-metadata.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/base.object-metadata.ts new file mode 100644 index 000000000..00b9eba05 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/base.object-metadata.ts @@ -0,0 +1,41 @@ +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceIsPimaryField } from 'src/engine/twenty-orm/decorators/workspace-is-primary-field.decorator'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; +import { BASE_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; + +export abstract class BaseObjectMetadata { + @WorkspaceField({ + standardId: BASE_OBJECT_STANDARD_FIELD_IDS.id, + type: FieldMetadataType.UUID, + label: 'Id', + description: 'Id', + defaultValue: 'uuid', + icon: 'Icon123', + }) + @WorkspaceIsPimaryField() + @WorkspaceIsSystem() + id: string; + + @WorkspaceField({ + standardId: BASE_OBJECT_STANDARD_FIELD_IDS.createdAt, + type: FieldMetadataType.DATE_TIME, + label: 'Creation date', + description: 'Creation date', + icon: 'IconCalendar', + defaultValue: 'now', + }) + @WorkspaceIsSystem() + createdAt: Date; + + @WorkspaceField({ + standardId: BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt, + type: FieldMetadataType.DATE_TIME, + label: 'Update date', + description: 'Update date', + icon: 'IconCalendar', + defaultValue: 'now', + }) + @WorkspaceIsSystem() + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/company.object-metadata.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/company.object-metadata.ts new file mode 100644 index 000000000..1235c9207 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/company.object-metadata.ts @@ -0,0 +1,130 @@ +import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; +import { LinkMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + RelationMetadataType, + RelationOnDeleteAction, +} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { COMPANY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { WorkspaceObject } from 'src/engine/twenty-orm/decorators/workspace-object.decorator'; +import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; +import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; +import { WorkspaceMemberObjectMetadata } from 'src/engine/twenty-orm/workspace-object-tests/workspace-member.object-metadata'; +import { BaseObjectMetadata } from 'src/engine/twenty-orm/workspace-object-tests/base.object-metadata'; +import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; + +@WorkspaceObject({ + standardId: STANDARD_OBJECT_IDS.company, + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + description: 'A company', + icon: 'IconBuildingSkyscraper', +}) +export class CompanyObjectMetadata extends BaseObjectMetadata { + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.name, + type: FieldMetadataType.TEXT, + label: 'Name', + description: 'The company name', + icon: 'IconBuildingSkyscraper', + }) + name: string; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.domainName, + type: FieldMetadataType.TEXT, + label: 'Domain Name', + description: + 'The company website URL. We use this url to fetch the company icon', + icon: 'IconLink', + }) + domainName?: string; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.address, + type: FieldMetadataType.TEXT, + label: 'Address', + description: 'The company address', + icon: 'IconMap', + }) + address: string; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.employees, + type: FieldMetadataType.NUMBER, + label: 'Employees', + description: 'Number of employees in the company', + icon: 'IconUsers', + }) + @WorkspaceIsNullable() + employees: number; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.linkedinLink, + type: FieldMetadataType.LINK, + label: 'Linkedin', + description: 'The company Linkedin account', + icon: 'IconBrandLinkedin', + }) + @WorkspaceIsNullable() + linkedinLink: LinkMetadata; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.xLink, + type: FieldMetadataType.LINK, + label: 'X', + description: 'The company Twitter/X account', + icon: 'IconBrandX', + }) + @WorkspaceIsNullable() + xLink: LinkMetadata; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue, + type: FieldMetadataType.CURRENCY, + label: 'ARR', + description: + 'Annual Recurring Revenue: The actual or estimated annual revenue of the company', + icon: 'IconMoneybag', + }) + @WorkspaceIsNullable() + annualRecurringRevenue: CurrencyMetadata; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.idealCustomerProfile, + type: FieldMetadataType.BOOLEAN, + label: 'ICP', + description: + 'Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you', + icon: 'IconTarget', + defaultValue: false, + }) + idealCustomerProfile: boolean; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.position, + type: FieldMetadataType.POSITION, + label: 'Position', + description: 'Company record position', + icon: 'IconHierarchy2', + }) + @WorkspaceIsSystem() + @WorkspaceIsNullable() + position: number; + + @WorkspaceRelation({ + standardId: COMPANY_STANDARD_FIELD_IDS.accountOwner, + label: 'Account Owner', + description: + 'Your team member responsible for managing the company account', + type: RelationMetadataType.MANY_TO_ONE, + inverseSideTarget: () => WorkspaceMemberObjectMetadata, + inverseSideFieldKey: 'accountOwnerForCompanies', + onDelete: RelationOnDeleteAction.SET_NULL, + }) + @WorkspaceIsNullable() + accountOwner: WorkspaceMemberObjectMetadata; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/workspace-member.object-metadata.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/workspace-member.object-metadata.ts new file mode 100644 index 000000000..291618d72 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/workspace-member.object-metadata.ts @@ -0,0 +1,110 @@ +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + RelationMetadataType, + RelationOnDeleteAction, +} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { WORKSPACE_MEMBER_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { AttachmentObjectMetadata } from 'src/modules/attachment/standard-objects/attachment.object-metadata'; +import { WorkspaceObject } from 'src/engine/twenty-orm/decorators/workspace-object.decorator'; +import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; +import { BaseObjectMetadata } from 'src/engine/twenty-orm/workspace-object-tests/base.object-metadata'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; +import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; +import { CompanyObjectMetadata } from 'src/engine/twenty-orm/workspace-object-tests/company.object-metadata'; + +@WorkspaceObject({ + standardId: STANDARD_OBJECT_IDS.workspaceMember, + namePlural: 'workspaceMembers', + labelSingular: 'Workspace Member', + labelPlural: 'Workspace Members', + description: 'A workspace member', + icon: 'IconUserCircle', +}) +@WorkspaceIsSystem() +@WorkspaceIsNotAuditLogged() +export class WorkspaceMemberObjectMetadata extends BaseObjectMetadata { + @WorkspaceField({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.name, + type: FieldMetadataType.FULL_NAME, + label: 'Name', + description: 'Workspace member name', + icon: 'IconCircleUser', + }) + name: FullNameMetadata; + + @WorkspaceField({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.colorScheme, + type: FieldMetadataType.TEXT, + label: 'Color Scheme', + description: 'Preferred color scheme', + icon: 'IconColorSwatch', + defaultValue: "'Light'", + }) + colorScheme: string; + + @WorkspaceField({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.locale, + type: FieldMetadataType.TEXT, + label: 'Language', + description: 'Preferred language', + icon: 'IconLanguage', + defaultValue: "'en'", + }) + locale: string; + + @WorkspaceField({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.avatarUrl, + type: FieldMetadataType.TEXT, + label: 'Avatar Url', + description: 'Workspace member avatar', + icon: 'IconFileUpload', + }) + avatarUrl: string; + + @WorkspaceField({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.userEmail, + type: FieldMetadataType.TEXT, + label: 'User Email', + description: 'Related user email address', + icon: 'IconMail', + }) + userEmail: string; + + @WorkspaceField({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.userId, + type: FieldMetadataType.UUID, + label: 'User Id', + description: 'Associated User Id', + icon: 'IconCircleUsers', + }) + userId: string; + + @WorkspaceRelation({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.authoredAttachments, + label: 'Authored attachments', + description: 'Attachments created by the workspace member', + icon: 'IconFileImport', + type: RelationMetadataType.ONE_TO_MANY, + inverseSideTarget: () => AttachmentObjectMetadata, + inverseSideFieldKey: 'author', + onDelete: RelationOnDeleteAction.SET_NULL, + }) + authoredAttachments: Relation; + + @WorkspaceRelation({ + standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.accountOwnerForCompanies, + label: 'Account Owner For Companies', + description: 'Account owner for companies', + icon: 'IconBriefcase', + type: RelationMetadataType.ONE_TO_MANY, + inverseSideTarget: () => CompanyObjectMetadata, + inverseSideFieldKey: 'accountOwner', + onDelete: RelationOnDeleteAction.SET_NULL, + }) + accountOwnerForCompanies: Relation; +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator.ts index 2ebb22cbe..b5e57e0e3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator.ts @@ -31,6 +31,7 @@ export function FieldMetadata( { ...restParams, standardId, + joinColumn, }, fieldKey, isNullable, @@ -49,6 +50,7 @@ export function FieldMetadata( defaultValue: null, options: undefined, settings: undefined, + joinColumn, }, joinColumn, isNullable, diff --git a/packages/twenty-server/src/utils/typed-reflect.ts b/packages/twenty-server/src/utils/typed-reflect.ts index 2e3463bc0..82c1e84a0 100644 --- a/packages/twenty-server/src/utils/typed-reflect.ts +++ b/packages/twenty-server/src/utils/typed-reflect.ts @@ -6,6 +6,7 @@ import { ReflectDynamicRelationFieldMetadata } from 'src/engine/workspace-manage import { ReflectFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-field-metadata.interface'; import { ReflectObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-object-metadata.interface'; import { ReflectRelationMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-relation-metadata.interface'; +import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; export interface ReflectMetadataTypeMap { objectMetadata: ReflectObjectMetadata; @@ -17,6 +18,12 @@ export interface ReflectMetadataTypeMap { isNullable: true; isSystem: true; isAuditLogged: false; + + ['workspace:is-nullable-metadata-args']: true; + ['workspace:gate-metadata-args']: Gate; + ['workspace:is-system-metadata-args']: true; + ['workspace:is-audit-logged-metadata-args']: false; + ['workspace:is-primary-field-metadata-args']: true; } export class TypedReflect {