diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index b71ff69d4dd8..4078a8823c80 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -64,6 +64,9 @@ export class GraphQLConfigService const config: YogaDriverConfig = { autoSchemaFile: true, include: [CoreEngineModule], + subscriptions: { + 'graphql-ws': true, + }, conditionalSchema: async (context) => { let user: User | undefined; let workspace: Workspace | undefined; 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 a8a22022ac2c..6c649e7530dd 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 @@ -10,6 +10,7 @@ import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timel import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/timeline-calendar-event.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { HealthModule } from 'src/engine/core-modules/health/health.module'; +import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { ClientConfigModule } from './client-config/client-config.module'; import { FileModule } from './file/file.module'; @@ -30,6 +31,7 @@ import { AnalyticsModule } from './analytics/analytics.module'; TimelineCalendarEventModule, UserModule, WorkspaceModule, + SubscriptionsModule, ], exports: [ AnalyticsModule, @@ -39,6 +41,7 @@ import { AnalyticsModule } from './analytics/analytics.module'; TimelineCalendarEventModule, UserModule, WorkspaceModule, + SubscriptionsModule, ], }) export class CoreEngineModule {} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts index bd1991ea0dc3..69a230bd4784 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts @@ -21,6 +21,7 @@ export enum FeatureFlagKeys { IsAirtableIntegrationEnabled = 'IS_AIRTABLE_INTEGRATION_ENABLED', IsPostgreSQLIntegrationEnabled = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IsStripeIntegrationEnabled = 'IS_STRIPE_INTEGRATION_ENABLED', + IsRealTimeSyncEnabled = 'IS_REAL_TIME_SYNC_ENABLED', } @Entity({ name: 'featureFlag', schema: 'core' }) diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts index 0e3d6e22c686..6316c47895f1 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts @@ -1,6 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; +@ObjectType() export class ObjectRecordCreateEvent extends ObjectRecordBaseEvent { + @Field(() => GraphQLJSON) properties: { after: T; }; diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts index 01e981bdc76e..ace3dc13f0e9 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts @@ -1,6 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; +@ObjectType() export class ObjectRecordDeleteEvent extends ObjectRecordBaseEvent { + @Field(() => GraphQLJSON) properties: { before: T; }; diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts index 037b38178c14..2115773e9722 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts @@ -1,6 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; +@ObjectType() export class ObjectRecordUpdateEvent extends ObjectRecordBaseEvent { + @Field(() => GraphQLJSON) properties: { before: T; after: T; diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts index d34fbd1afcc5..02c7fa2421a7 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts @@ -1,11 +1,25 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; +@ObjectType() export class ObjectRecordBaseEvent { + @Field(() => String) name: string; + + @Field(() => String) workspaceId: string; + + @Field(() => String) recordId: string; + + @Field(() => String, { nullable: true }) userId?: string; + + @Field(() => String, { nullable: true }) workspaceMemberId?: string; + + @Field(() => ObjectMetadataInterface) objectMetadata: ObjectMetadataInterface; properties: any; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts index 7367a1dac737..fc4ff1b50b7f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts @@ -1,3 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + 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 { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; @@ -5,21 +9,40 @@ import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadat import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; -export interface FieldMetadataInterface< +@ObjectType() +export class FieldMetadataInterface< T extends FieldMetadataType | 'default' = 'default', > { id: string; type: FieldMetadataType; name: string; label: string; + + @Field(() => GraphQLJSON, { nullable: true }) defaultValue?: FieldMetadataDefaultValue; + + @Field(() => [GraphQLJSON], { nullable: true }) options?: FieldMetadataOptions; settings?: FieldMetadataSettings; + + @Field(() => String) objectMetadataId: string; + + @Field(() => String, { nullable: true }) workspaceId?: string; + + @Field(() => String, { nullable: true }) description?: string; + + @Field(() => Boolean, { nullable: true }) isNullable?: boolean; + + @Field(() => RelationMetadataEntity, { nullable: true }) fromRelationMetadata?: RelationMetadataEntity; + + @Field(() => RelationMetadataEntity, { nullable: true }) toRelationMetadata?: RelationMetadataEntity; + + @Field(() => Boolean, { nullable: true }) isCustom?: boolean; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts index 4454c94747a5..31cde7d71c56 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts @@ -1,21 +1,53 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + import { RelationMetadataInterface } from './relation-metadata.interface'; import { FieldMetadataInterface } from './field-metadata.interface'; -export interface ObjectMetadataInterface { +@ObjectType() +export class ObjectMetadataInterface { + @Field(() => String) id: string; standardId?: string | null; + + @Field(() => String) nameSingular: string; + + @Field(() => String) namePlural: string; + + @Field(() => String) labelSingular: string; + + @Field(() => String) labelPlural: string; + + @Field(() => String, { nullable: true }) description?: string; + + @Field(() => String) targetTableName: string; + + @Field(() => [RelationMetadataInterface], { nullable: true }) fromRelations: RelationMetadataInterface[]; + + @Field(() => [RelationMetadataInterface], { nullable: true }) toRelations: RelationMetadataInterface[]; + + @Field(() => [FieldMetadataInterface]) fields: FieldMetadataInterface[]; + + @Field(() => Boolean) isSystem: boolean; + + @Field(() => Boolean) isCustom: boolean; + + @Field(() => Boolean) isActive: boolean; + + @Field(() => Boolean) isRemote: boolean; + + @Field(() => Boolean) isAuditLogged: boolean; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-metadata.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-metadata.interface.ts index b1342b4b8396..78a10b3ffefd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-metadata.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-metadata.interface.ts @@ -1,22 +1,37 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { ObjectMetadataInterface } from './object-metadata.interface'; import { FieldMetadataInterface } from './field-metadata.interface'; -export interface RelationMetadataInterface { +@ObjectType() +export class RelationMetadataInterface { + @Field(() => ID) id: string; + @Field(() => RelationMetadataType) relationType: RelationMetadataType; + @Field(() => String) fromObjectMetadataId: string; + fromObjectMetadata: ObjectMetadataInterface; + @Field(() => String) toObjectMetadataId: string; + toObjectMetadata: ObjectMetadataInterface; + @Field(() => String) fromFieldMetadataId: string; + + @Field(() => FieldMetadataInterface) fromFieldMetadata: FieldMetadataInterface; + @Field(() => String) toFieldMetadataId: string; + + @Field(() => FieldMetadataInterface) toFieldMetadata: FieldMetadataInterface; } 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 fe0ffe86ef7e..61b45b1c05c8 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 @@ -1,3 +1,5 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + import { Column, CreateDateColumn, @@ -30,11 +32,14 @@ export enum RelationOnDeleteAction { } @Entity('relationMetadata') +@ObjectType() export class RelationMetadataEntity implements RelationMetadataInterface { @PrimaryGeneratedColumn('uuid') + @Field(() => ID) id: string; @Column({ nullable: false }) + @Field(() => RelationMetadataType) relationType: RelationMetadataType; @Column({ @@ -43,21 +48,27 @@ export class RelationMetadataEntity implements RelationMetadataInterface { type: 'enum', enum: RelationOnDeleteAction, }) + @Field(() => String) onDeleteAction: RelationOnDeleteAction; @Column({ nullable: false, type: 'uuid' }) + @Field(() => String) fromObjectMetadataId: string; @Column({ nullable: false, type: 'uuid' }) + @Field(() => String) toObjectMetadataId: string; @Column({ nullable: false, type: 'uuid' }) + @Field(() => String) fromFieldMetadataId: string; @Column({ nullable: false, type: 'uuid' }) + @Field(() => String) toFieldMetadataId: string; @Column({ nullable: false, type: 'uuid' }) + @Field(() => String) workspaceId: string; @ManyToOne( @@ -92,9 +103,11 @@ export class RelationMetadataEntity implements RelationMetadataInterface { @JoinColumn() toFieldMetadata: Relation; + @Field(() => Date) @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; + @Field(() => Date) @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; } diff --git a/packages/twenty-server/src/engine/subscriptions/events.listener.ts b/packages/twenty-server/src/engine/subscriptions/events.listener.ts new file mode 100644 index 000000000000..0c6437681fe5 --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/events.listener.ts @@ -0,0 +1,28 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { PubSub } from 'graphql-subscriptions'; + +import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event'; + +@Injectable() +export class EventsListener { + constructor(@Inject('PUB_SUB') private readonly pubSub: PubSub) {} + + @OnEvent('*.created') + async handleCreatedEvent(payload: ObjectRecordCreateEvent) { + this.pubSub.publish('recordCreated', { recordCreated: payload }); + } + + @OnEvent('*.updated') + async handleUpdatedEvent(payload: ObjectRecordUpdateEvent) { + this.pubSub.publish('recordUpdated', { recordUpdated: payload }); + } + + @OnEvent('*.deleted') + async handleDeletedEvent(payload: ObjectRecordDeleteEvent) { + this.pubSub.publish('recordDeleted', { recordDeleted: payload }); + } +} diff --git a/packages/twenty-server/src/engine/subscriptions/subscriptions.module.ts b/packages/twenty-server/src/engine/subscriptions/subscriptions.module.ts new file mode 100644 index 000000000000..a3644ef9a13a --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/subscriptions.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; + +import { PubSub } from 'graphql-subscriptions'; + +import { EventsListener } from 'src/engine/subscriptions/events.listener'; +import { SubscriptionsResolver } from 'src/engine/subscriptions/subscriptions.resolver'; + +@Module({ + imports: [], + exports: [], + providers: [ + { + provide: 'PUB_SUB', + useFactory: () => { + return new PubSub(); + }, + }, + SubscriptionsResolver, + EventsListener, + ], +}) +export class SubscriptionsModule {} diff --git a/packages/twenty-server/src/engine/subscriptions/subscriptions.resolver.ts b/packages/twenty-server/src/engine/subscriptions/subscriptions.resolver.ts new file mode 100644 index 000000000000..452593560743 --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/subscriptions.resolver.ts @@ -0,0 +1,28 @@ +import { Inject } from '@nestjs/common'; +import { Resolver, Subscription } from '@nestjs/graphql'; + +import { PubSub } from 'graphql-subscriptions'; + +import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event'; + +@Resolver() +export class SubscriptionsResolver { + constructor(@Inject('PUB_SUB') private readonly pubSub: PubSub) {} + + @Subscription(() => ObjectRecordCreateEvent) + recordCreated() { + return this.pubSub.asyncIterator('recordCreated'); + } + + @Subscription(() => ObjectRecordUpdateEvent) + recordUpdated() { + return this.pubSub.asyncIterator('recordUpdated'); + } + + @Subscription(() => ObjectRecordDeleteEvent) + recordDeleted() { + return this.pubSub.asyncIterator('recordDeleted'); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts index 5959f5791fbb..02dcad71fea8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts @@ -57,6 +57,7 @@ export class AddStandardIdCommand extends CommandRunner { IS_EVENT_OBJECT_ENABLED: true, IS_AIRTABLE_INTEGRATION_ENABLED: true, IS_POSTGRESQL_INTEGRATION_ENABLED: true, + IS_REAL_TIME_SYNC_ENABLED: true, IS_STRIPE_INTEGRATION_ENABLED: false, }, ); @@ -71,6 +72,7 @@ export class AddStandardIdCommand extends CommandRunner { IS_EVENT_OBJECT_ENABLED: true, IS_AIRTABLE_INTEGRATION_ENABLED: true, IS_POSTGRESQL_INTEGRATION_ENABLED: true, + IS_REAL_TIME_SYNC_ENABLED: true, IS_STRIPE_INTEGRATION_ENABLED: false, }, );