From e2185448ed8c3f5d30000e124a5029554b6318e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Mon, 29 Apr 2024 16:47:42 +0200 Subject: [PATCH] Feat/twenty orm (#5153) ## Introduction This PR introduces "TwentyORM," a custom ORM module designed to streamline database interactions within our workspace schema, reducing the need for raw SQL queries. The API mirrors TypeORM's to provide a familiar interface while integrating enhancements specific to our project's needs. To facilitate this integration, new decorators prefixed with `Workspace` have been implemented. These decorators are used to define entity metadata more explicitly and are critical in constructing our schema dynamically. ## New Features - **Custom ORM System**: Named "TwentyORM," which aligns closely with TypeORM for ease of use but is tailored to our application's specific requirements. - **Decorator-Driven Configuration**: Entities are now configured with `Workspace`-prefixed decorators that clearly define schema mappings and relationships directly within the entity classes. - **Injectable Repositories**: Repositories can be injected similarly to TypeORM, allowing for flexible and straightforward data management. ## Example Implementations ### Decorated Entity Definitions Entities are defined with new decorators that outline table and field metadata, relationships, and constraints. Here are examples of these implementations: #### Company Metadata Object ```typescript @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.xLink, type: FieldMetadataType.LINK, label: 'X', description: 'The company Twitter/X account', icon: 'IconBrandX', }) @WorkspaceIsNullable() xLink: LinkMetadata; @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; } ``` #### Workspace Member Metadata Object ```typescript @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; @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 ; } ``` ### Injectable Repository Usage Repositories can be directly injected into services, allowing for streamlined query operations: ```typescript export class CompanyService { constructor( @InjectWorkspaceRepository(CompanyObjectMetadata) private readonly companyObjectMetadataRepository: WorkspaceRepository, ) {} async companies(): Promise { // Example queries demonstrating simple and relation-loaded operations const simpleCompanies = await this.companyObjectMetadataRepository.find({}); const companiesWithOwners = await this.companyObjectMetadataRepository.find({ relations: ['accountOwner'], }); const companiesFilteredByLinkLabel = await this.companyObjectMetadataRepository.find({ where: { xLinkLabel: 'MyLabel' }, }); return companiesFilteredByLinkLabel; } } ``` ## Conclusions This PR sets the foundation for a decorator-driven ORM layer that simplifies data interactions and supports complex entity relationships while maintaining clean and manageable code architecture. This is not finished yet, and should be extended. All the standard objects needs to be migrated and all the module using the old decorators too. --------- Co-authored-by: Weiko --- .../engine/core-modules/core-engine.module.ts | 4 +- .../field-metadata/composite-types/index.ts | 26 +++- .../field-metadata/field-metadata.service.ts | 5 +- .../utils/compute-column-name.util.ts | 33 ++++- .../relation-metadata.entity.ts | 1 + .../inject-workspace-datasource.decorator.ts | 6 + .../inject-workspace-repository.decorator.ts | 8 ++ .../decorators/workspace-field.decorator.ts | 63 +++++++++ .../decorators/workspace-gate.decorator.ts | 24 ++++ ...workspace-is-not-audit-logged.decorator.ts | 11 ++ .../workspace-is-nullable.decorator.ts | 12 ++ .../workspace-is-primary-field.decorator.ts | 12 ++ .../workspace-is-system.decorator.ts | 20 +++ .../decorators/workspace-object.decorator.ts | 47 +++++++ .../workspace-relation.decorator.ts | 88 ++++++++++++ .../factories/entity-schema-column.factory.ts | 104 ++++++++++++++ .../entity-schema-relation.factory.ts | 61 ++++++++ .../factories/entity-schema.factory.ts | 43 ++++++ .../src/engine/twenty-orm/factories/index.ts | 11 ++ .../factories/workspace-datasource.factory.ts | 58 ++++++++ .../flatten-composite-types.interface.ts | 9 ++ .../twenty-orm/interfaces/gate.interface.ts | 3 + .../twenty-orm-options.interface.ts | 12 ++ ...workspace-field-metadata-args.interface.ts | 76 ++++++++++ ...orkspace-object-metadata-args.interface.ts | 53 +++++++ ...kspace-relation-metadata-args.interface.ts | 89 ++++++++++++ .../repository/workspace.repository.ts | 7 + .../twenty-orm/storage/data-source.storage.ts | 17 +++ .../storage/metadata-args.storage.ts | 95 +++++++++++++ .../twenty-orm/twenty-orm-core.module.ts | 130 ++++++++++++++++++ .../engine/twenty-orm/twenty-orm.constants.ts | 2 + .../twenty-orm.module-definition.ts | 10 ++ .../engine/twenty-orm/twenty-orm.module.ts | 41 ++++++ .../engine/twenty-orm/twenty-orm.providers.ts | 28 ++++ .../engine/twenty-orm/twenty-orm.service.ts | 27 ++++ .../get-workspace-repository-token.util.ts | 24 ++++ .../attachment.object-metadata.ts | 61 ++++++++ .../base.object-metadata.ts | 41 ++++++ .../company.object-metadata.ts | 130 ++++++++++++++++++ .../workspace-member.object-metadata.ts | 110 +++++++++++++++ .../decorators/field-metadata.decorator.ts | 2 + .../twenty-server/src/utils/typed-reflect.ts | 7 + 42 files changed, 1597 insertions(+), 14 deletions(-) create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-datasource.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/inject-workspace-repository.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-nullable.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-primary-field.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-system.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-object.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-relation.decorator.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/factories/entity-schema.factory.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/factories/index.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/interfaces/flatten-composite-types.interface.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/interfaces/twenty-orm-options.interface.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-object-metadata-args.interface.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/storage/data-source.storage.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/storage/metadata-args.storage.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/twenty-orm-core.module.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/twenty-orm.constants.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/twenty-orm.module-definition.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/twenty-orm.providers.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/twenty-orm.service.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/get-workspace-repository-token.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/attachment.object-metadata.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/base.object-metadata.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/company.object-metadata.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-object-tests/workspace-member.object-metadata.ts 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 {