From e811bae10e5e95d2cb53bb3f34745f571dbc1bd6 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 16 Oct 2024 14:32:06 +0200 Subject: [PATCH] Execute variables in action input (#7715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - send context from all previous steps rather than unique payload - wrap input data in settings into input field - add email into send email action settings - update output shape Capture d’écran 2024-10-15 à 15 21 32 Capture d’écran 2024-10-15 à 15 20 09 --- .../WorkflowEditActionFormSendEmail.tsx | 57 +++++++---- ...rkflowEditActionFormServerlessFunction.tsx | 6 +- .../src/modules/workflow/types/Workflow.ts | 13 ++- .../__tests__/addCreateStepNodes.test.ts | 8 +- .../__tests__/generateWorkflowDiagram.test.ts | 16 ++- .../getWorkflowVersionDiagram.test.ts | 4 +- .../utils/__tests__/insertStep.test.ts | 32 ++++-- .../utils/__tests__/removeStep.test.ts | 16 ++- .../utils/__tests__/replaceStep.test.ts | 20 +++- .../utils/getStepDefaultDefinition.ts | 13 ++- .../send-email.workflow-action.ts | 62 +++++------- .../workflow-actions/code.workflow-action.ts | 22 ++--- .../workflow-run.workspace-entity.ts | 14 ++- .../exceptions/workflow-executor.exception.ts | 1 + .../interfaces/workflow-action.interface.ts | 9 +- .../types/workflow-step-settings.type.ts | 13 ++- .../__tests__/variable-resolver.util.spec.ts | 81 +++++++++++++++ .../utils/variable-resolver.util.ts | 98 +++++++++++++++++++ .../workflow-executor.module.ts | 10 +- .../workflow-executor.workspace-service.ts | 63 ++++++------ .../workflow-runner/jobs/run-workflow.job.ts | 6 +- 21 files changed, 409 insertions(+), 155 deletions(-) create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx index be2a495202..f7d3255c12 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx @@ -1,21 +1,21 @@ +import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope'; +import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; +import { Select, SelectOption } from '@/ui/input/components/Select'; import { TextArea } from '@/ui/input/components/TextArea'; import { TextInput } from '@/ui/input/components/TextInput'; import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase'; +import { workflowIdState } from '@/workflow/states/workflowIdState'; import { WorkflowSendEmailStep } from '@/workflow/types/Workflow'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { useRecoilValue } from 'recoil'; import { IconMail, IconPlus, isDefined } from 'twenty-ui'; import { useDebouncedCallback } from 'use-debounce'; -import { Select, SelectOption } from '@/ui/input/components/Select'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; -import { useRecoilValue } from 'recoil'; -import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; -import { workflowIdState } from '@/workflow/states/workflowIdState'; -import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope'; const StyledTriggerSettings = styled.div` padding: ${({ theme }) => theme.spacing(6)}; @@ -37,6 +37,7 @@ type WorkflowEditActionFormSendEmailProps = type SendEmailFormData = { connectedAccountId: string; + email: string; subject: string; body: string; }; @@ -53,6 +54,7 @@ export const WorkflowEditActionFormSendEmail = ( const form = useForm({ defaultValues: { connectedAccountId: '', + email: '', subject: '', body: '', }, @@ -83,10 +85,11 @@ export const WorkflowEditActionFormSendEmail = ( useEffect(() => { form.setValue( 'connectedAccountId', - props.action.settings.connectedAccountId ?? '', + props.action.settings.input.connectedAccountId ?? '', ); - form.setValue('subject', props.action.settings.subject ?? ''); - form.setValue('body', props.action.settings.body ?? ''); + form.setValue('email', props.action.settings.input.email ?? ''); + form.setValue('subject', props.action.settings.input.subject ?? ''); + form.setValue('body', props.action.settings.input.body ?? ''); }, [props.action.settings, form]); const saveAction = useDebouncedCallback( @@ -99,9 +102,12 @@ export const WorkflowEditActionFormSendEmail = ( ...props.action, settings: { ...props.action.settings, - connectedAccountId: formData.connectedAccountId, - subject: formData.subject, - body: formData.body, + input: { + connectedAccountId: formData.connectedAccountId, + email: formData.email, + subject: formData.subject, + body: formData.body, + }, }, }); @@ -134,12 +140,12 @@ export const WorkflowEditActionFormSendEmail = ( }; if ( - isDefined(props.action.settings.connectedAccountId) && - props.action.settings.connectedAccountId !== '' + isDefined(props.action.settings.input.connectedAccountId) && + props.action.settings.input.connectedAccountId !== '' ) { filter.or.push({ id: { - eq: props.action.settings.connectedAccountId, + eq: props.action.settings.input.connectedAccountId, }, }); } @@ -198,6 +204,21 @@ export const WorkflowEditActionFormSendEmail = ( /> )} /> + ( + { + field.onChange(email); + handleSave(); + }} + /> + )} + /> { @@ -66,7 +66,9 @@ export const WorkflowEditActionFormServerlessFunction = ( ...props.action, settings: { ...props.action.settings, - serverlessFunctionId: updatedFunction, + input: { + serverlessFunctionId: updatedFunction, + }, }, }); }} diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 0ed8422846..65b2e9a25a 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -10,13 +10,18 @@ type BaseWorkflowStepSettings = { }; export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { - serverlessFunctionId: string; + input: { + serverlessFunctionId: string; + }; }; export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { - connectedAccountId: string; - subject?: string; - body?: string; + input: { + connectedAccountId: string; + email: string; + subject?: string; + body?: string; + }; }; type BaseWorkflowStep = { diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/addCreateStepNodes.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/addCreateStepNodes.test.ts index d0f46dde35..c2c9d760fe 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/addCreateStepNodes.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/addCreateStepNodes.test.ts @@ -21,7 +21,9 @@ describe('addCreateStepNodes', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, }, { @@ -34,7 +36,9 @@ describe('addCreateStepNodes', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, }, ]; diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts index ebf4f3c210..663e1ef566 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts @@ -42,7 +42,9 @@ describe('generateWorkflowDiagram', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, }, { @@ -55,7 +57,9 @@ describe('generateWorkflowDiagram', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, }, ]; @@ -96,7 +100,9 @@ describe('generateWorkflowDiagram', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, }, { @@ -109,7 +115,9 @@ describe('generateWorkflowDiagram', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, }, ]; diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVersionDiagram.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVersionDiagram.test.ts index 907524a725..9c10c2af4f 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVersionDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVersionDiagram.test.ts @@ -80,7 +80,9 @@ describe('getWorkflowVersionDiagram', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/insertStep.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/insertStep.test.ts index ba93aa6c34..b264d5edaf 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/insertStep.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/insertStep.test.ts @@ -25,7 +25,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -63,7 +65,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -95,7 +99,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -108,7 +114,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -129,7 +137,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -165,7 +175,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -178,7 +190,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -199,7 +213,9 @@ describe('insertStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/removeStep.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/removeStep.test.ts index 01385411bf..349d2f7420 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/removeStep.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/removeStep.test.ts @@ -10,7 +10,9 @@ it('returns a deep copy of the provided steps array instead of mutating it', () retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'first', + input: { + serverlessFunctionId: 'first', + }, }, type: 'CODE', valid: true, @@ -47,7 +49,9 @@ it('removes a step in a non-empty steps array', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -67,7 +71,9 @@ it('removes a step in a non-empty steps array', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -81,7 +87,9 @@ it('removes a step in a non-empty steps array', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/replaceStep.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/replaceStep.test.ts index 93286c5901..41e4f8cbae 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/replaceStep.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/replaceStep.test.ts @@ -11,7 +11,9 @@ describe('replaceStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'first', + input: { + serverlessFunctionId: 'first', + }, }, type: 'CODE', valid: true, @@ -39,7 +41,9 @@ describe('replaceStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'second', + input: { + serverlessFunctionId: 'second', + }, }, }, stepId: stepToBeReplaced.id, @@ -57,7 +61,9 @@ describe('replaceStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -77,7 +83,9 @@ describe('replaceStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, @@ -91,7 +99,9 @@ describe('replaceStep', () => { retryOnFailure: { value: true }, continueOnFailure: { value: false }, }, - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, }, type: 'CODE', valid: true, diff --git a/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts b/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts index 48e8f9bd44..fd63158d25 100644 --- a/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts +++ b/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts @@ -14,7 +14,9 @@ export const getStepDefaultDefinition = ( type: 'CODE', valid: false, settings: { - serverlessFunctionId: '', + input: { + serverlessFunctionId: '', + }, errorHandlingOptions: { continueOnFailure: { value: false, @@ -33,9 +35,12 @@ export const getStepDefaultDefinition = ( type: 'SEND_EMAIL', valid: false, settings: { - connectedAccountId: '', - subject: '', - body: '', + input: { + connectedAccountId: '', + email: '', + subject: '', + body: '', + }, errorHandlingOptions: { continueOnFailure: { value: false, diff --git a/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts b/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts index 026b4537d8..103045f88f 100644 --- a/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts +++ b/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts @@ -1,40 +1,37 @@ import { Injectable, Logger } from '@nestjs/common'; -import { z } from 'zod'; -import Handlebars from 'handlebars'; -import { JSDOM } from 'jsdom'; import DOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { z } from 'zod'; + +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; -import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/types/workflow-action-result.type'; -import { WorkflowSendEmailStep } from 'src/modules/workflow/workflow-executor/types/workflow-action.type'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; -import { - WorkflowStepExecutorException, - WorkflowStepExecutorExceptionCode, -} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { MailSenderException, MailSenderExceptionCode, } from 'src/modules/mail-sender/exceptions/mail-sender.exception'; import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/types/workflow-action-result.type'; +import { WorkflowSendEmailStepInput } from 'src/modules/workflow/workflow-executor/types/workflow-step-settings.type'; import { isDefined } from 'src/utils/is-defined'; @Injectable() -export class SendEmailWorkflowAction { +export class SendEmailWorkflowAction implements WorkflowAction { private readonly logger = new Logger(SendEmailWorkflowAction.name); constructor( - private readonly environmentService: EnvironmentService, - private readonly emailService: EmailService, private readonly gmailClientProvider: GmailClientProvider, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} - private async getEmailClient(step: WorkflowSendEmailStep) { + private async getEmailClient(connectedAccountId: string) { const { workspaceId } = this.scopedWorkspaceContextFactory.create(); if (!workspaceId) { @@ -50,12 +47,12 @@ export class SendEmailWorkflowAction { 'connectedAccount', ); const connectedAccount = await connectedAccountRepository.findOneBy({ - id: step.settings.connectedAccountId, + id: connectedAccountId, }); if (!isDefined(connectedAccount)) { throw new MailSenderException( - `Connected Account '${step.settings.connectedAccountId}' not found`, + `Connected Account '${connectedAccountId}' not found`, MailSenderExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND, ); } @@ -71,39 +68,32 @@ export class SendEmailWorkflowAction { } } - async execute({ - step, - payload, - }: { - step: WorkflowSendEmailStep; - payload: { - email: string; - [key: string]: string; - }; - }): Promise { - const emailProvider = await this.getEmailClient(step); + async execute( + workflowStepInput: WorkflowSendEmailStepInput, + ): Promise { + const emailProvider = await this.getEmailClient( + workflowStepInput.connectedAccountId, + ); + const { email, body, subject } = workflowStepInput; try { const emailSchema = z.string().trim().email('Invalid email'); - const result = emailSchema.safeParse(payload.email); + const result = emailSchema.safeParse(email); if (!result.success) { - this.logger.warn(`Email '${payload.email}' invalid`); + this.logger.warn(`Email '${email}' invalid`); return { result: { success: false } }; } - const body = Handlebars.compile(step.settings.body)(payload); - const subject = Handlebars.compile(step.settings.subject)(payload); - const window = new JSDOM('').window; const purify = DOMPurify(window); const safeBody = purify.sanitize(body || ''); const safeSubject = purify.sanitize(subject || ''); const message = [ - `To: ${payload.email}`, + `To: ${email}`, `Subject: ${safeSubject || ''}`, 'MIME-Version: 1.0', 'Content-Type: text/plain; charset="UTF-8"', diff --git a/packages/twenty-server/src/modules/serverless/workflow-actions/code.workflow-action.ts b/packages/twenty-server/src/modules/serverless/workflow-actions/code.workflow-action.ts index 1e42dcf81e..f239c3c794 100644 --- a/packages/twenty-server/src/modules/serverless/workflow-actions/code.workflow-action.ts +++ b/packages/twenty-server/src/modules/serverless/workflow-actions/code.workflow-action.ts @@ -1,28 +1,26 @@ import { Injectable } from '@nestjs/common'; +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; + import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/types/workflow-action-result.type'; -import { WorkflowCodeStep } from 'src/modules/workflow/workflow-executor/types/workflow-action.type'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/types/workflow-action-result.type'; +import { WorkflowCodeStepInput } from 'src/modules/workflow/workflow-executor/types/workflow-step-settings.type'; @Injectable() -export class CodeWorkflowAction { +export class CodeWorkflowAction implements WorkflowAction { constructor( private readonly serverlessFunctionService: ServerlessFunctionService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, ) {} - async execute({ - step, - payload, - }: { - step: WorkflowCodeStep; - payload?: object; - }): Promise { + async execute( + workflowStepInput: WorkflowCodeStepInput, + ): Promise { const { workspaceId } = this.scopedWorkspaceContextFactory.create(); if (!workspaceId) { @@ -34,9 +32,9 @@ export class CodeWorkflowAction { const result = await this.serverlessFunctionService.executeOneServerlessFunction( - step.settings.serverlessFunctionId, + workflowStepInput.serverlessFunctionId, workspaceId, - payload || {}, + {}, // TODO: input will be dynamically calculated from function input ); if (result.error) { diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts index aa14605f74..545e88a470 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts @@ -32,17 +32,21 @@ export enum WorkflowRunStatus { FAILED = 'FAILED', } -export type WorkflowRunOutput = { - steps: { - id: string; - name: string; - type: string; +type StepRunOutput = { + id: string; + name: string; + type: string; + outputs: { attemptCount: number; result: object | undefined; error: string | undefined; }[]; }; +export type WorkflowRunOutput = { + steps: Record; +}; + @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.workflowRun, namePlural: 'workflowRuns', diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts index cdba717b8f..aee8cc34dc 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts @@ -8,4 +8,5 @@ export class WorkflowExecutorException extends CustomException { export enum WorkflowExecutorExceptionCode { WORKFLOW_FAILED = 'WORKFLOW_FAILED', + VARIABLE_EVALUATION_FAILED = 'VARIABLE_EVALUATION_FAILED', } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/interfaces/workflow-action.interface.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/interfaces/workflow-action.interface.ts index 47b481949a..499baaed84 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/interfaces/workflow-action.interface.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/interfaces/workflow-action.interface.ts @@ -1,12 +1,5 @@ import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/types/workflow-action-result.type'; -import { WorkflowStep } from 'src/modules/workflow/workflow-executor/types/workflow-action.type'; export interface WorkflowAction { - execute({ - step, - payload, - }: { - step: WorkflowStep; - payload?: object; - }): Promise; + execute(workflowStepInput: unknown): Promise; } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts index bb8f8351fa..1cc9b20c9c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts @@ -9,12 +9,21 @@ type BaseWorkflowStepSettings = { }; }; -export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { +export type WorkflowCodeStepInput = { serverlessFunctionId: string; }; -export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { +export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { + input: WorkflowCodeStepInput; +}; + +export type WorkflowSendEmailStepInput = { connectedAccountId: string; + email: string; subject?: string; body?: string; }; + +export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { + input: WorkflowSendEmailStepInput; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts new file mode 100644 index 0000000000..0e73ec4a2c --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts @@ -0,0 +1,81 @@ +import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util'; + +describe('resolveInput', () => { + const context = { + user: { + name: 'John Doe', + age: 30, + }, + settings: { + theme: 'dark', + notifications: true, + }, + }; + + it('should return null for null input', () => { + expect(resolveInput(null, context)).toBeNull(); + }); + + it('should return undefined for undefined input', () => { + expect(resolveInput(undefined, context)).toBeUndefined(); + }); + + it('should resolve a simple string variable', () => { + expect(resolveInput('{{user.name}}', context)).toBe('John Doe'); + }); + + it('should resolve multiple variables in a string', () => { + expect( + resolveInput('Name: {{user.name}}, Age: {{user.age}}', context), + ).toBe('Name: John Doe, Age: 30'); + }); + + it('should handle non-existent variables', () => { + expect(resolveInput('{{user.email}}', context)).toBe(''); + }); + + it('should resolve variables in an array', () => { + const input = ['{{user.name}}', '{{settings.theme}}', 'static']; + const expected = ['John Doe', 'dark', 'static']; + + expect(resolveInput(input, context)).toEqual(expected); + }); + + it('should resolve variables in an object', () => { + const input = { + name: '{{user.name}}', + theme: '{{settings.theme}}', + static: 'value', + }; + const expected = { + name: 'John Doe', + theme: 'dark', + static: 'value', + }; + + expect(resolveInput(input, context)).toEqual(expected); + }); + + it('should handle nested objects and arrays', () => { + const input = { + user: { + displayName: '{{user.name}}', + preferences: ['{{settings.theme}}', '{{settings.notifications}}'], + }, + staticData: [1, 2, 3], + }; + const expected = { + user: { + displayName: 'John Doe', + preferences: ['dark', 'true'], + }, + staticData: [1, 2, 3], + }; + + expect(resolveInput(input, context)).toEqual(expected); + }); + + it('should throw an error for invalid expressions', () => { + expect(() => resolveInput('{{invalidFunction()}}', context)).toThrow(); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts new file mode 100644 index 0000000000..c4fc012d45 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts @@ -0,0 +1,98 @@ +import { isNil, isString } from '@nestjs/common/utils/shared.utils'; + +import Handlebars from 'handlebars'; + +import { + WorkflowExecutorException, + WorkflowExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception'; + +const VARIABLE_PATTERN = RegExp('\\{\\{(.*?)\\}\\}', 'g'); + +export const resolveInput = ( + unresolvedInput: unknown, + context: Record, +): unknown => { + if (isNil(unresolvedInput)) { + return unresolvedInput; + } + + if (isString(unresolvedInput)) { + return resolveString(unresolvedInput, context); + } + + if (Array.isArray(unresolvedInput)) { + return resolveArray(unresolvedInput, context); + } + + if (typeof unresolvedInput === 'object' && unresolvedInput !== null) { + return resolveObject(unresolvedInput, context); + } + + return unresolvedInput; +}; + +const resolveArray = ( + input: unknown[], + context: Record, +): unknown[] => { + const resolvedArray = input; + + for (let i = 0; i < input.length; ++i) { + resolvedArray[i] = resolveInput(input[i], context); + } + + return resolvedArray; +}; + +const resolveObject = ( + input: object, + context: Record, +): object => { + const resolvedObject = input; + + const entries = Object.entries(resolvedObject); + + for (const [key, value] of entries) { + resolvedObject[key] = resolveInput(value, context); + } + + return resolvedObject; +}; + +const resolveString = ( + input: string, + context: Record, +): string => { + const matchedTokens = input.match(VARIABLE_PATTERN); + + if (!matchedTokens || matchedTokens.length === 0) { + return input; + } + + if (matchedTokens.length === 1 && matchedTokens[0] === input) { + return evalFromContext(input, context); + } + + return input.replace(VARIABLE_PATTERN, (matchedToken, _) => { + const processedToken = evalFromContext(matchedToken, context); + + return processedToken; + }); +}; + +const evalFromContext = ( + input: string, + context: Record, +): string => { + try { + const inferredInput = Handlebars.compile(input)(context); + + return inferredInput ?? ''; + } catch (exception) { + throw new WorkflowExecutorException( + `Failed to evaluate variable ${input}: ${exception}`, + WorkflowExecutorExceptionCode.VARIABLE_EVALUATION_FAILED, + ); + } +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts index 24ae66fd7f..4cfc2d9888 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts @@ -1,13 +1,13 @@ import { Module } from '@nestjs/common'; -import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; -import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service'; -import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory'; -import { CodeWorkflowAction } from 'src/modules/serverless/workflow-actions/code.workflow-action'; -import { SendEmailWorkflowAction } from 'src/modules/mail-sender/workflow-actions/send-email.workflow-action'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { SendEmailWorkflowAction } from 'src/modules/mail-sender/workflow-actions/send-email.workflow-action'; import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module'; +import { CodeWorkflowAction } from 'src/modules/serverless/workflow-actions/code.workflow-action'; +import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; +import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory'; +import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service'; @Module({ imports: [ diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts index c50684f876..5290c5d4d0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts @@ -6,6 +6,7 @@ import { } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory'; import { WorkflowStep } from 'src/modules/workflow/workflow-executor/types/workflow-action.type'; +import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util'; const MAX_RETRIES_ON_FAILURE = 3; @@ -21,14 +22,14 @@ export class WorkflowExecutorWorkspaceService { async execute({ currentStepIndex, steps, - payload, + context, output, attemptCount = 1, }: { currentStepIndex: number; steps: WorkflowStep[]; output: WorkflowExecutorOutput; - payload?: object; + context: Record; attemptCount?: number; }): Promise { if (currentStepIndex >= steps.length) { @@ -39,59 +40,55 @@ export class WorkflowExecutorWorkspaceService { const workflowAction = this.workflowActionFactory.get(step.type); - const result = await workflowAction.execute({ - step, - payload, - }); + const actionPayload = resolveInput(step.settings.input, context); - const baseStepOutput = { + const result = await workflowAction.execute(actionPayload); + + const stepOutput = output.steps[step.id]; + + const error = + result.error?.errorMessage ?? + (result.result ? undefined : 'Execution result error, no data or error'); + + const updatedStepOutput = { id: step.id, name: step.name, type: step.type, - attemptCount, + outputs: [ + ...(stepOutput?.outputs ?? []), + { + attemptCount, + result: result.result, + error, + }, + ], }; const updatedOutput = { ...output, - steps: [ + steps: { ...output.steps, - { - ...baseStepOutput, - result: result.result, - error: result.error?.errorMessage, - }, - ], + [step.id]: updatedStepOutput, + }, }; if (result.result) { return await this.execute({ currentStepIndex: currentStepIndex + 1, steps, - payload: result.result, + context: { + ...context, + [step.id]: result.result, + }, output: updatedOutput, }); } - if (!result.error) { - return { - ...output, - steps: [ - ...output.steps, - { - ...baseStepOutput, - result: undefined, - error: 'Execution result error, no data or error', - }, - ], - status: WorkflowRunStatus.FAILED, - }; - } - if (step.settings.errorHandlingOptions.continueOnFailure.value) { return await this.execute({ currentStepIndex: currentStepIndex + 1, steps, - payload, + context, output: updatedOutput, }); } @@ -103,7 +100,7 @@ export class WorkflowExecutorWorkspaceService { return await this.execute({ currentStepIndex, steps, - payload, + context, output: updatedOutput, attemptCount: attemptCount + 1, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts index 5a79462a35..644595df6f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts @@ -40,9 +40,11 @@ export class RunWorkflowJob { await this.workflowExecutorWorkspaceService.execute({ currentStepIndex: 0, steps: workflowVersion.steps || [], - payload, + context: { + trigger: payload, + }, output: { - steps: [], + steps: {}, status: WorkflowRunStatus.RUNNING, }, });