From a2a272fed460d9e066e28c3d0d1cdfd4546796ef Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Fri, 15 Nov 2024 10:10:00 +0100 Subject: [PATCH] Add crud actions (#8500) Adding update / delete / find actions Update and delete are not really different than creation. Find uses the same logique as for graphql filters. Expected formats are: Filter ``` { "and": [ { "name": { "eq": "salut" } }, { "employees": { "eq": "0" } } ] } ``` Order `[ { "name": 'AscNullsFirst' } ]` --- .../graphql-query.parser.ts | 2 +- .../workflow-builder.workspace-service.ts | 39 ++- .../record-crud-action.exception.ts | 13 + .../record-crud/record-crud-action.module.ts | 5 +- .../record-crud.workflow-action.ts | 249 +++++++++++++++++- .../workflow-record-crud-action-input.type.ts | 17 +- 6 files changed, 313 insertions(+), 12 deletions(-) create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index aa7700c567..99157e10f7 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -54,7 +54,7 @@ export class GraphqlQueryParser { public applyDeletedAtToBuilder( queryBuilder: SelectQueryBuilder, - recordFilter: ObjectRecordFilter, + recordFilter: Partial, ): SelectQueryBuilder { if (this.checkForDeletedAtFilter(recordFilter)) { queryBuilder.withDeleted(); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts index 17e595c0de..67ad2f1cad 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts @@ -10,10 +10,12 @@ import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; +import { generateFakeValue } from 'src/engine/utils/generate-fake-value'; import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service'; import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record'; import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event'; import { WorkflowSendEmailStepOutputSchema } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action'; +import { WorkflowRecordCRUDType } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; import { WorkflowAction, WorkflowActionType, @@ -44,7 +46,7 @@ export class WorkflowBuilderWorkspaceService { switch (stepType) { case WorkflowTriggerType.DATABASE_EVENT: { - return await this.computeDatabaseEventTriggerOutputSchema({ + return this.computeDatabaseEventTriggerOutputSchema({ eventName: step.settings.eventName, workspaceId, objectMetadataRepository: this.objectMetadataRepository, @@ -57,7 +59,7 @@ export class WorkflowBuilderWorkspaceService { return {}; } - return await this.computeRecordOutputSchema({ + return this.computeRecordOutputSchema({ objectType, workspaceId, objectMetadataRepository: this.objectMetadataRepository, @@ -70,7 +72,7 @@ export class WorkflowBuilderWorkspaceService { const { serverlessFunctionId, serverlessFunctionVersion } = step.settings.input; - return await this.computeCodeActionOutputSchema({ + return this.computeCodeActionOutputSchema({ serverlessFunctionId, serverlessFunctionVersion, workspaceId, @@ -79,8 +81,9 @@ export class WorkflowBuilderWorkspaceService { }); } case WorkflowActionType.RECORD_CRUD: - return await this.computeRecordOutputSchema({ + return this.computeRecordCrudOutputSchema({ objectType: step.settings.input.objectName, + operationType: step.settings.input.type, workspaceId, objectMetadataRepository: this.objectMetadataRepository, }); @@ -122,6 +125,34 @@ export class WorkflowBuilderWorkspaceService { ); } + private async computeRecordCrudOutputSchema({ + objectType, + operationType, + workspaceId, + objectMetadataRepository, + }: { + objectType: string; + operationType: string; + workspaceId: string; + objectMetadataRepository: Repository; + }) { + const recordOutputSchema = await this.computeRecordOutputSchema({ + objectType, + workspaceId, + objectMetadataRepository, + }); + + if (operationType === WorkflowRecordCRUDType.READ) { + return { + first: recordOutputSchema, + last: recordOutputSchema, + totalCount: generateFakeValue('number'), + }; + } + + return recordOutputSchema; + } + private async computeRecordOutputSchema({ objectType, workspaceId, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts new file mode 100644 index 0000000000..b7d55b59f8 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts @@ -0,0 +1,13 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class RecordCRUDActionException extends CustomException { + code: RecordCRUDActionExceptionCode; + constructor(message: string, code: RecordCRUDActionExceptionCode) { + super(message, code); + } +} + +export enum RecordCRUDActionExceptionCode { + INVALID_REQUEST = 'INVALID_REQUEST', + RECORD_NOT_FOUND = 'RECORD_NOT_FOUND', +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts index 4f978fb568..03a1a693cd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; +import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { RecordCRUDWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud.workflow-action'; @Module({ - providers: [RecordCRUDWorkflowAction], + imports: [WorkspaceCacheStorageModule], + providers: [RecordCRUDWorkflowAction, ScopedWorkspaceContextFactory], exports: [RecordCRUDWorkflowAction], }) export class RecordCRUDActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud.workflow-action.ts index 44c688626b..39f688de4d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud.workflow-action.ts @@ -1,18 +1,45 @@ import { Injectable } from '@nestjs/common'; +import { Entity } from '@microsoft/microsoft-graph-types'; +import { ObjectLiteral } from 'typeorm'; + +import { + ObjectRecordFilter, + ObjectRecordOrderBy, + OrderByDirection, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; +import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { + RecordCRUDActionException, + RecordCRUDActionExceptionCode, +} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception'; import { WorkflowCreateRecordActionInput, + WorkflowDeleteRecordActionInput, + WorkflowReadRecordActionInput, WorkflowRecordCRUDActionInput, WorkflowRecordCRUDType, + WorkflowUpdateRecordActionInput, } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-result.type'; @Injectable() export class RecordCRUDWorkflowAction implements WorkflowAction { - constructor(private readonly twentyORMManager: TwentyORMManager) {} + constructor( + private readonly twentyORMManager: TwentyORMManager, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, + ) {} async execute( workflowActionInput: WorkflowRecordCRUDActionInput, @@ -20,9 +47,16 @@ export class RecordCRUDWorkflowAction implements WorkflowAction { switch (workflowActionInput.type) { case WorkflowRecordCRUDType.CREATE: return this.createRecord(workflowActionInput); + case WorkflowRecordCRUDType.DELETE: + return this.deleteRecord(workflowActionInput); + case WorkflowRecordCRUDType.UPDATE: + return this.updateRecord(workflowActionInput); + case WorkflowRecordCRUDType.READ: + return this.findRecords(workflowActionInput); default: - throw new Error( - `Unknown record operation type: ${workflowActionInput.type}`, + throw new RecordCRUDActionException( + `Unknown record operation type`, + RecordCRUDActionExceptionCode.INVALID_REQUEST, ); } } @@ -38,8 +72,213 @@ export class RecordCRUDWorkflowAction implements WorkflowAction { workflowActionInput.objectRecord, ); - const createdObjectRecord = await repository.save(objectRecord); + await repository.save(objectRecord); - return { result: createdObjectRecord }; + return { + result: objectRecord, + }; + } + + private async updateRecord( + workflowActionInput: WorkflowUpdateRecordActionInput, + ): Promise { + const repository = await this.twentyORMManager.getRepository( + workflowActionInput.objectName, + ); + + const objectRecord = await repository.findOne({ + where: { + id: workflowActionInput.objectRecordId, + }, + }); + + if (!objectRecord) { + throw new RecordCRUDActionException( + `Failed to update: Record ${workflowActionInput.objectName} with id ${workflowActionInput.objectRecordId} not found`, + RecordCRUDActionExceptionCode.RECORD_NOT_FOUND, + ); + } + + await repository.update(workflowActionInput.objectRecordId, { + ...workflowActionInput.objectRecord, + }); + + return { + result: { + ...objectRecord, + ...workflowActionInput.objectRecord, + }, + }; + } + + private async deleteRecord( + workflowActionInput: WorkflowDeleteRecordActionInput, + ): Promise { + const repository = await this.twentyORMManager.getRepository( + workflowActionInput.objectName, + ); + + const objectRecord = await repository.findOne({ + where: { + id: workflowActionInput.objectRecordId, + }, + }); + + if (!objectRecord) { + throw new RecordCRUDActionException( + `Failed to delete: Record ${workflowActionInput.objectName} with id ${workflowActionInput.objectRecordId} not found`, + RecordCRUDActionExceptionCode.RECORD_NOT_FOUND, + ); + } + + await repository.update(workflowActionInput.objectRecordId, { + deletedAt: new Date(), + }); + + return { + result: objectRecord, + }; + } + + private async findRecords( + workflowActionInput: WorkflowReadRecordActionInput, + ): Promise { + const repository = await this.twentyORMManager.getRepository( + workflowActionInput.objectName, + ); + const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; + + if (!workspaceId) { + throw new RecordCRUDActionException( + 'Failed to read: Workspace ID is required', + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + + const currentCacheVersion = + await this.workspaceCacheStorageService.getMetadataVersion(workspaceId); + + if (currentCacheVersion === undefined) { + throw new RecordCRUDActionException( + 'Failed to read: Metadata cache version not found', + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMaps( + workspaceId, + currentCacheVersion, + ); + + if (!objectMetadataMaps) { + throw new RecordCRUDActionException( + 'Failed to read: Object metadata collection not found', + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + + const objectMetadataItemWithFieldsMaps = + objectMetadataMaps.byNameSingular[workflowActionInput.objectName]; + + if (!objectMetadataItemWithFieldsMaps) { + throw new RecordCRUDActionException( + `Failed to read: Object ${workflowActionInput.objectName} not found`, + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + + const graphqlQueryParser = new GraphqlQueryParser( + objectMetadataItemWithFieldsMaps.fieldsByName, + objectMetadataMaps, + ); + + const objectRecords = await this.getObjectRecords( + workflowActionInput, + objectMetadataItemWithFieldsMaps, + objectMetadataMaps, + repository, + graphqlQueryParser, + ); + + const totalCount = await this.getTotalCount( + workflowActionInput, + repository, + graphqlQueryParser, + ); + + return { + result: { + first: objectRecords[0], + last: objectRecords[objectRecords.length - 1], + totalCount, + }, + }; + } + + private async getObjectRecords( + workflowActionInput: WorkflowReadRecordActionInput, + objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps, + objectMetadataMaps: ObjectMetadataMaps, + repository: WorkspaceRepository, + graphqlQueryParser: GraphqlQueryParser, + ) { + const queryBuilder = repository.createQueryBuilder( + workflowActionInput.objectName, + ); + + const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + queryBuilder, + workflowActionInput.objectName, + workflowActionInput.filter ?? ({} as ObjectRecordFilter), + ); + + const orderByWithIdCondition = [ + ...(workflowActionInput.orderBy ?? []), + { id: OrderByDirection.AscNullsFirst }, + ] as ObjectRecordOrderBy; + + const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder( + withFilterQueryBuilder, + orderByWithIdCondition, + workflowActionInput.objectName, + false, + ); + + const nonFormattedObjectRecords = await withOrderByQueryBuilder + .take(workflowActionInput.limit ?? QUERY_MAX_RECORDS) + .getMany(); + + return formatResult( + nonFormattedObjectRecords, + objectMetadataItemWithFieldsMaps, + objectMetadataMaps, + ); + } + + private async getTotalCount( + workflowActionInput: WorkflowReadRecordActionInput, + repository: WorkspaceRepository, + graphqlQueryParser: GraphqlQueryParser, + ) { + const countQueryBuilder = repository.createQueryBuilder( + workflowActionInput.objectName, + ); + + const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + countQueryBuilder, + workflowActionInput.objectName, + workflowActionInput.filter ?? ({} as ObjectRecordFilter), + ); + + const withDeletedCountQueryBuilder = + graphqlQueryParser.applyDeletedAtToBuilder( + withFilterCountQueryBuilder, + workflowActionInput.filter + ? workflowActionInput.filter + : ({} as ObjectRecordFilter), + ); + + return withDeletedCountQueryBuilder.getCount(); } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts index 43bb9ffbda..314eef3c30 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts @@ -1,9 +1,15 @@ +import { + ObjectRecordFilter, + ObjectRecordOrderBy, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + type ObjectRecord = Record; export enum WorkflowRecordCRUDType { CREATE = 'create', UPDATE = 'update', DELETE = 'delete', + READ = 'read', } export type WorkflowCreateRecordActionInput = { @@ -25,7 +31,16 @@ export type WorkflowDeleteRecordActionInput = { objectRecordId: string; }; +export type WorkflowReadRecordActionInput = { + type: WorkflowRecordCRUDType.READ; + objectName: string; + filter?: Partial; + orderBy?: Partial; + limit?: number; +}; + export type WorkflowRecordCRUDActionInput = | WorkflowCreateRecordActionInput | WorkflowUpdateRecordActionInput - | WorkflowDeleteRecordActionInput; + | WorkflowDeleteRecordActionInput + | WorkflowReadRecordActionInput;