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

<CompanyObjectMetadata[]>;
}
```

### 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<CompanyObjectMetadata>,
  ) {}

  async companies(): Promise<CompanyObjectMetadata[]> {
    // 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 <corentin@twenty.com>
This commit is contained in:
Jérémy M
2024-04-29 16:47:42 +02:00
committed by GitHub
parent 6e87554445
commit e2185448ed
42 changed files with 1597 additions and 14 deletions

View File

@ -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: [

View File

@ -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;

View File

@ -503,7 +503,10 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
relationMetadata.fromFieldMetadata.id === fieldMetadataDTO.id;
// TODO: implement MANY_TO_MANY
if (relationMetadata.relationType === RelationMetadataType.MANY_TO_MANY) {
if (
relationMetadata.relationType === RelationMetadataType.MANY_TO_MANY ||
relationMetadata.relationType === RelationMetadataType.MANY_TO_ONE
) {
throw new Error(`
Relation type ${relationMetadata.relationType} not supported
`);

View File

@ -36,18 +36,37 @@ export function computeColumnName<T extends FieldMetadataType | 'default'>(
return generateName(fieldMetadataOrFieldName.name);
}
export const computeCompositeColumnName = <
export function computeCompositeColumnName(
fieldName: string,
compositeProperty: CompositeProperty,
): string;
export function computeCompositeColumnName<
T extends FieldMetadataType | 'default',
>(
fieldMetadata: FieldMetadataInterface<T>,
compositeProperty: CompositeProperty,
): string => {
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
): string;
export function computeCompositeColumnName<
T extends FieldMetadataType | 'default',
>(
fieldMetadataOrFieldName: FieldMetadataInterface<T> | 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,
)}`;
}

View File

@ -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',
}

View File

@ -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);

View File

@ -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<typeof Inject> => Inject(getWorkspaceRepositoryToken(entity));

View File

@ -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<T>;
joinColumn?: string;
options?: FieldMetadataOptions<T>;
}
export function WorkspaceField<T extends FieldMetadataType>(
options: WorkspaceFieldOptions<T>,
): 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,
});
};
}

View File

@ -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,
);
}
};
}

View File

@ -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,
);
};
}

View File

@ -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(),
);
};
}

View File

@ -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(),
);
};
}

View File

@ -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,
);
}
};
}

View File

@ -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,
});
};
}

View File

@ -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<TType, TClass> {
standardId: string;
label: string;
description?: string;
icon?: string;
type: TType;
inverseSideTarget: () => ObjectType<TClass>;
inverseSideFieldKey?: keyof TClass;
onDelete?: RelationOnDeleteAction;
}
export interface WorkspaceManyToOneRelationOptions<TClass>
extends WorkspaceBaseRelationOptions<
RelationMetadataType.MANY_TO_ONE | RelationMetadataType.ONE_TO_ONE,
TClass
> {
joinColumn?: string;
}
export interface WorkspaceOtherRelationOptions<TClass>
extends WorkspaceBaseRelationOptions<
RelationMetadataType.ONE_TO_MANY | RelationMetadataType.MANY_TO_MANY,
TClass
> {}
export function WorkspaceRelation<TClass extends object>(
options:
| WorkspaceManyToOneRelationOptions<TClass>
| WorkspaceOtherRelationOptions<TClass>,
): 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,
});
};
}

View File

@ -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;
}
}

View File

@ -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');
}
}
}

View File

@ -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<T>(target: Type<T>): 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,
});
}
}

View File

@ -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,
];

View File

@ -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;
}
}

View File

@ -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<T> = {
[P in keyof T as T[P] extends CompositeMetadataTypes
? `${string & P}${Capitalize<string & keyof T[P]>}`
: P]: T[P] extends CompositeMetadataTypes ? T[P][keyof T[P]] : T[P];
};

View File

@ -0,0 +1,3 @@
export interface Gate {
featureFlag: string;
}

View File

@ -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<BaseObjectMetadata>[];
}
export type TwentyORMModuleAsyncOptions = {
useFactory: (...args: any[]) => TwentyORMOptions | Promise<TwentyORMOptions>;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<object>;
/**
* 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;
}

View File

@ -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<FlattenCompositeTypes<Entity>> {}

View File

@ -0,0 +1,17 @@
import { DataSource } from 'typeorm';
export class DataSourceStorage {
private static readonly dataSources: Map<string, DataSource> = 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());
}
}

View File

@ -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<T extends { target: Function | string }>(
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<T extends { target: Function | string }>(
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<T extends { target: Function | string }>(
array: T[],
targetString: string,
): T[] {
return array.filter((item) => item.target === targetString);
}
}
export const metadataArgsStorage = new MetadataArgsStorage();

View File

@ -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);
}
}
}
}

View File

@ -0,0 +1,2 @@
export const TWENTY_ORM_WORKSPACE_DATASOURCE =
'TWENTY_ORM_WORKSPACE_DATASOURCE';

View File

@ -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<TwentyORMOptions>().build();

View File

@ -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)],
};
}
}

View File

@ -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],
}));
}

View File

@ -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<T extends ObjectLiteral>(
entityClass: Type<T>,
): Repository<FlattenCompositeTypes<T>> {
const entitySchema = this.entitySchemaFactory.create(entityClass);
return this.workspaceDataSource.getRepository<FlattenCompositeTypes<T>>(
entitySchema,
);
}
}

View File

@ -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`;
}

View File

@ -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<WorkspaceMemberObjectMetadata>;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<AttachmentObjectMetadata[]>;
@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<CompanyObjectMetadata[]>;
}

View File

@ -31,6 +31,7 @@ export function FieldMetadata<T extends FieldMetadataType>(
{
...restParams,
standardId,
joinColumn,
},
fieldKey,
isNullable,
@ -49,6 +50,7 @@ export function FieldMetadata<T extends FieldMetadataType>(
defaultValue: null,
options: undefined,
settings: undefined,
joinColumn,
},
joinColumn,
isNullable,

View File

@ -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 {