Execute variables in action input (#7715)

- 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

<img width="553" alt="Capture d’écran 2024-10-15 à 15 21 32"
src="https://github.com/user-attachments/assets/0f5ed004-0d6e-4a59-969b-a5710f3f3985">

<img width="761" alt="Capture d’écran 2024-10-15 à 15 20 09"
src="https://github.com/user-attachments/assets/ac140846-c383-483b-968a-eab469b76785">
This commit is contained in:
Thomas Trompette 2024-10-16 14:32:06 +02:00 committed by GitHub
parent a88c2fa453
commit e811bae10e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 409 additions and 155 deletions

View File

@ -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 { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase'; import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow'; import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import React, { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui'; import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce'; 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` const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)}; padding: ${({ theme }) => theme.spacing(6)};
@ -37,6 +37,7 @@ type WorkflowEditActionFormSendEmailProps =
type SendEmailFormData = { type SendEmailFormData = {
connectedAccountId: string; connectedAccountId: string;
email: string;
subject: string; subject: string;
body: string; body: string;
}; };
@ -53,6 +54,7 @@ export const WorkflowEditActionFormSendEmail = (
const form = useForm<SendEmailFormData>({ const form = useForm<SendEmailFormData>({
defaultValues: { defaultValues: {
connectedAccountId: '', connectedAccountId: '',
email: '',
subject: '', subject: '',
body: '', body: '',
}, },
@ -83,10 +85,11 @@ export const WorkflowEditActionFormSendEmail = (
useEffect(() => { useEffect(() => {
form.setValue( form.setValue(
'connectedAccountId', 'connectedAccountId',
props.action.settings.connectedAccountId ?? '', props.action.settings.input.connectedAccountId ?? '',
); );
form.setValue('subject', props.action.settings.subject ?? ''); form.setValue('email', props.action.settings.input.email ?? '');
form.setValue('body', props.action.settings.body ?? ''); form.setValue('subject', props.action.settings.input.subject ?? '');
form.setValue('body', props.action.settings.input.body ?? '');
}, [props.action.settings, form]); }, [props.action.settings, form]);
const saveAction = useDebouncedCallback( const saveAction = useDebouncedCallback(
@ -99,10 +102,13 @@ export const WorkflowEditActionFormSendEmail = (
...props.action, ...props.action,
settings: { settings: {
...props.action.settings, ...props.action.settings,
input: {
connectedAccountId: formData.connectedAccountId, connectedAccountId: formData.connectedAccountId,
email: formData.email,
subject: formData.subject, subject: formData.subject,
body: formData.body, body: formData.body,
}, },
},
}); });
if (checkScopes === true) { if (checkScopes === true) {
@ -134,12 +140,12 @@ export const WorkflowEditActionFormSendEmail = (
}; };
if ( if (
isDefined(props.action.settings.connectedAccountId) && isDefined(props.action.settings.input.connectedAccountId) &&
props.action.settings.connectedAccountId !== '' props.action.settings.input.connectedAccountId !== ''
) { ) {
filter.or.push({ filter.or.push({
id: { id: {
eq: props.action.settings.connectedAccountId, eq: props.action.settings.input.connectedAccountId,
}, },
}); });
} }
@ -198,6 +204,21 @@ export const WorkflowEditActionFormSendEmail = (
/> />
)} )}
/> />
<Controller
name="email"
control={form.control}
render={({ field }) => (
<TextInput
label="Email"
placeholder="Enter receiver email (use {{variable}} for dynamic content)"
value={field.value}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
<Controller <Controller
name="subject" name="subject"
control={form.control} control={form.control}

View File

@ -54,7 +54,7 @@ export const WorkflowEditActionFormServerlessFunction = (
dropdownId="workflow-edit-action-function" dropdownId="workflow-edit-action-function"
label="Function" label="Function"
fullWidth fullWidth
value={props.action.settings.serverlessFunctionId} value={props.action.settings.input.serverlessFunctionId}
options={availableFunctions} options={availableFunctions}
disabled={props.readonly} disabled={props.readonly}
onChange={(updatedFunction) => { onChange={(updatedFunction) => {
@ -66,8 +66,10 @@ export const WorkflowEditActionFormServerlessFunction = (
...props.action, ...props.action,
settings: { settings: {
...props.action.settings, ...props.action.settings,
input: {
serverlessFunctionId: updatedFunction, serverlessFunctionId: updatedFunction,
}, },
},
}); });
}} }}
/> />

View File

@ -10,14 +10,19 @@ type BaseWorkflowStepSettings = {
}; };
export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
input: {
serverlessFunctionId: string; serverlessFunctionId: string;
}; };
};
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
input: {
connectedAccountId: string; connectedAccountId: string;
email: string;
subject?: string; subject?: string;
body?: string; body?: string;
}; };
};
type BaseWorkflowStep = { type BaseWorkflowStep = {
id: string; id: string;

View File

@ -21,9 +21,11 @@ describe('addCreateStepNodes', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
}, },
},
{ {
id: 'step2', id: 'step2',
name: 'Step 2', name: 'Step 2',
@ -34,9 +36,11 @@ describe('addCreateStepNodes', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
}, },
},
]; ];
const diagramInitial = generateWorkflowDiagram({ trigger, steps }); const diagramInitial = generateWorkflowDiagram({ trigger, steps });

View File

@ -42,9 +42,11 @@ describe('generateWorkflowDiagram', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
}, },
},
{ {
id: 'step2', id: 'step2',
name: 'Step 2', name: 'Step 2',
@ -55,9 +57,11 @@ describe('generateWorkflowDiagram', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
}, },
},
]; ];
const result = generateWorkflowDiagram({ trigger, steps }); const result = generateWorkflowDiagram({ trigger, steps });
@ -96,9 +100,11 @@ describe('generateWorkflowDiagram', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
}, },
},
{ {
id: 'step2', id: 'step2',
name: 'Step 2', name: 'Step 2',
@ -109,9 +115,11 @@ describe('generateWorkflowDiagram', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
}, },
},
]; ];
const result = generateWorkflowDiagram({ trigger, steps }); const result = generateWorkflowDiagram({ trigger, steps });

View File

@ -80,8 +80,10 @@ describe('getWorkflowVersionDiagram', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },

View File

@ -25,8 +25,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}; };
@ -63,8 +65,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}; };
@ -95,8 +99,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },
@ -108,8 +114,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },
@ -129,8 +137,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}; };
@ -165,8 +175,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },
@ -178,8 +190,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },
@ -199,8 +213,10 @@ describe('insertStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}; };

View File

@ -10,8 +10,10 @@ it('returns a deep copy of the provided steps array instead of mutating it', ()
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'first', serverlessFunctionId: 'first',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
} satisfies WorkflowStep; } satisfies WorkflowStep;
@ -47,8 +49,10 @@ it('removes a step in a non-empty steps array', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}; };
@ -67,8 +71,10 @@ it('removes a step in a non-empty steps array', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },
@ -81,8 +87,10 @@ it('removes a step in a non-empty steps array', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },

View File

@ -11,8 +11,10 @@ describe('replaceStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'first', serverlessFunctionId: 'first',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
} satisfies WorkflowStep; } satisfies WorkflowStep;
@ -39,9 +41,11 @@ describe('replaceStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'second', serverlessFunctionId: 'second',
}, },
}, },
},
stepId: stepToBeReplaced.id, stepId: stepToBeReplaced.id,
}); });
@ -57,8 +61,10 @@ describe('replaceStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}; };
@ -77,8 +83,10 @@ describe('replaceStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },
@ -91,8 +99,10 @@ describe('replaceStep', () => {
retryOnFailure: { value: true }, retryOnFailure: { value: true },
continueOnFailure: { value: false }, continueOnFailure: { value: false },
}, },
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
}, },
},
type: 'CODE', type: 'CODE',
valid: true, valid: true,
}, },

View File

@ -14,7 +14,9 @@ export const getStepDefaultDefinition = (
type: 'CODE', type: 'CODE',
valid: false, valid: false,
settings: { settings: {
input: {
serverlessFunctionId: '', serverlessFunctionId: '',
},
errorHandlingOptions: { errorHandlingOptions: {
continueOnFailure: { continueOnFailure: {
value: false, value: false,
@ -33,9 +35,12 @@ export const getStepDefaultDefinition = (
type: 'SEND_EMAIL', type: 'SEND_EMAIL',
valid: false, valid: false,
settings: { settings: {
input: {
connectedAccountId: '', connectedAccountId: '',
email: '',
subject: '', subject: '',
body: '', body: '',
},
errorHandlingOptions: { errorHandlingOptions: {
continueOnFailure: { continueOnFailure: {
value: false, value: false,

View File

@ -1,40 +1,37 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { z } from 'zod';
import Handlebars from 'handlebars';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify'; 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 { 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 { import {
MailSenderException, MailSenderException,
MailSenderExceptionCode, MailSenderExceptionCode,
} from 'src/modules/mail-sender/exceptions/mail-sender.exception'; } 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 { 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'; import { isDefined } from 'src/utils/is-defined';
@Injectable() @Injectable()
export class SendEmailWorkflowAction { export class SendEmailWorkflowAction implements WorkflowAction {
private readonly logger = new Logger(SendEmailWorkflowAction.name); private readonly logger = new Logger(SendEmailWorkflowAction.name);
constructor( constructor(
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
private readonly gmailClientProvider: GmailClientProvider, private readonly gmailClientProvider: GmailClientProvider,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {} ) {}
private async getEmailClient(step: WorkflowSendEmailStep) { private async getEmailClient(connectedAccountId: string) {
const { workspaceId } = this.scopedWorkspaceContextFactory.create(); const { workspaceId } = this.scopedWorkspaceContextFactory.create();
if (!workspaceId) { if (!workspaceId) {
@ -50,12 +47,12 @@ export class SendEmailWorkflowAction {
'connectedAccount', 'connectedAccount',
); );
const connectedAccount = await connectedAccountRepository.findOneBy({ const connectedAccount = await connectedAccountRepository.findOneBy({
id: step.settings.connectedAccountId, id: connectedAccountId,
}); });
if (!isDefined(connectedAccount)) { if (!isDefined(connectedAccount)) {
throw new MailSenderException( throw new MailSenderException(
`Connected Account '${step.settings.connectedAccountId}' not found`, `Connected Account '${connectedAccountId}' not found`,
MailSenderExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND, MailSenderExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
); );
} }
@ -71,39 +68,32 @@ export class SendEmailWorkflowAction {
} }
} }
async execute({ async execute(
step, workflowStepInput: WorkflowSendEmailStepInput,
payload, ): Promise<WorkflowActionResult> {
}: { const emailProvider = await this.getEmailClient(
step: WorkflowSendEmailStep; workflowStepInput.connectedAccountId,
payload: { );
email: string; const { email, body, subject } = workflowStepInput;
[key: string]: string;
};
}): Promise<WorkflowActionResult> {
const emailProvider = await this.getEmailClient(step);
try { try {
const emailSchema = z.string().trim().email('Invalid email'); const emailSchema = z.string().trim().email('Invalid email');
const result = emailSchema.safeParse(payload.email); const result = emailSchema.safeParse(email);
if (!result.success) { if (!result.success) {
this.logger.warn(`Email '${payload.email}' invalid`); this.logger.warn(`Email '${email}' invalid`);
return { result: { success: false } }; 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 window = new JSDOM('').window;
const purify = DOMPurify(window); const purify = DOMPurify(window);
const safeBody = purify.sanitize(body || ''); const safeBody = purify.sanitize(body || '');
const safeSubject = purify.sanitize(subject || ''); const safeSubject = purify.sanitize(subject || '');
const message = [ const message = [
`To: ${payload.email}`, `To: ${email}`,
`Subject: ${safeSubject || ''}`, `Subject: ${safeSubject || ''}`,
'MIME-Version: 1.0', 'MIME-Version: 1.0',
'Content-Type: text/plain; charset="UTF-8"', 'Content-Type: text/plain; charset="UTF-8"',

View File

@ -1,28 +1,26 @@
import { Injectable } from '@nestjs/common'; 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 { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; 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 { import {
WorkflowStepExecutorException, WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode, WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; } 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() @Injectable()
export class CodeWorkflowAction { export class CodeWorkflowAction implements WorkflowAction {
constructor( constructor(
private readonly serverlessFunctionService: ServerlessFunctionService, private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
) {} ) {}
async execute({ async execute(
step, workflowStepInput: WorkflowCodeStepInput,
payload, ): Promise<WorkflowActionResult> {
}: {
step: WorkflowCodeStep;
payload?: object;
}): Promise<WorkflowActionResult> {
const { workspaceId } = this.scopedWorkspaceContextFactory.create(); const { workspaceId } = this.scopedWorkspaceContextFactory.create();
if (!workspaceId) { if (!workspaceId) {
@ -34,9 +32,9 @@ export class CodeWorkflowAction {
const result = const result =
await this.serverlessFunctionService.executeOneServerlessFunction( await this.serverlessFunctionService.executeOneServerlessFunction(
step.settings.serverlessFunctionId, workflowStepInput.serverlessFunctionId,
workspaceId, workspaceId,
payload || {}, {}, // TODO: input will be dynamically calculated from function input
); );
if (result.error) { if (result.error) {

View File

@ -32,17 +32,21 @@ export enum WorkflowRunStatus {
FAILED = 'FAILED', FAILED = 'FAILED',
} }
export type WorkflowRunOutput = { type StepRunOutput = {
steps: {
id: string; id: string;
name: string; name: string;
type: string; type: string;
outputs: {
attemptCount: number; attemptCount: number;
result: object | undefined; result: object | undefined;
error: string | undefined; error: string | undefined;
}[]; }[];
}; };
export type WorkflowRunOutput = {
steps: Record<string, StepRunOutput>;
};
@WorkspaceEntity({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.workflowRun, standardId: STANDARD_OBJECT_IDS.workflowRun,
namePlural: 'workflowRuns', namePlural: 'workflowRuns',

View File

@ -8,4 +8,5 @@ export class WorkflowExecutorException extends CustomException {
export enum WorkflowExecutorExceptionCode { export enum WorkflowExecutorExceptionCode {
WORKFLOW_FAILED = 'WORKFLOW_FAILED', WORKFLOW_FAILED = 'WORKFLOW_FAILED',
VARIABLE_EVALUATION_FAILED = 'VARIABLE_EVALUATION_FAILED',
} }

View File

@ -1,12 +1,5 @@
import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/types/workflow-action-result.type'; 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 { export interface WorkflowAction {
execute({ execute(workflowStepInput: unknown): Promise<WorkflowActionResult>;
step,
payload,
}: {
step: WorkflowStep;
payload?: object;
}): Promise<WorkflowActionResult>;
} }

View File

@ -9,12 +9,21 @@ type BaseWorkflowStepSettings = {
}; };
}; };
export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { export type WorkflowCodeStepInput = {
serverlessFunctionId: string; serverlessFunctionId: string;
}; };
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
input: WorkflowCodeStepInput;
};
export type WorkflowSendEmailStepInput = {
connectedAccountId: string; connectedAccountId: string;
email: string;
subject?: string; subject?: string;
body?: string; body?: string;
}; };
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
input: WorkflowSendEmailStepInput;
};

View File

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

View File

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

View File

@ -1,13 +1,13 @@
import { Module } from '@nestjs/common'; 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 { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; 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 { 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({ @Module({
imports: [ imports: [

View File

@ -6,6 +6,7 @@ import {
} from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory'; 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 { 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; const MAX_RETRIES_ON_FAILURE = 3;
@ -21,14 +22,14 @@ export class WorkflowExecutorWorkspaceService {
async execute({ async execute({
currentStepIndex, currentStepIndex,
steps, steps,
payload, context,
output, output,
attemptCount = 1, attemptCount = 1,
}: { }: {
currentStepIndex: number; currentStepIndex: number;
steps: WorkflowStep[]; steps: WorkflowStep[];
output: WorkflowExecutorOutput; output: WorkflowExecutorOutput;
payload?: object; context: Record<string, unknown>;
attemptCount?: number; attemptCount?: number;
}): Promise<WorkflowExecutorOutput> { }): Promise<WorkflowExecutorOutput> {
if (currentStepIndex >= steps.length) { if (currentStepIndex >= steps.length) {
@ -39,59 +40,55 @@ export class WorkflowExecutorWorkspaceService {
const workflowAction = this.workflowActionFactory.get(step.type); const workflowAction = this.workflowActionFactory.get(step.type);
const result = await workflowAction.execute({ const actionPayload = resolveInput(step.settings.input, context);
step,
payload,
});
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, id: step.id,
name: step.name, name: step.name,
type: step.type, type: step.type,
outputs: [
...(stepOutput?.outputs ?? []),
{
attemptCount, attemptCount,
result: result.result,
error,
},
],
}; };
const updatedOutput = { const updatedOutput = {
...output, ...output,
steps: [ steps: {
...output.steps, ...output.steps,
{ [step.id]: updatedStepOutput,
...baseStepOutput,
result: result.result,
error: result.error?.errorMessage,
}, },
],
}; };
if (result.result) { if (result.result) {
return await this.execute({ return await this.execute({
currentStepIndex: currentStepIndex + 1, currentStepIndex: currentStepIndex + 1,
steps, steps,
payload: result.result, context: {
...context,
[step.id]: result.result,
},
output: updatedOutput, 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) { if (step.settings.errorHandlingOptions.continueOnFailure.value) {
return await this.execute({ return await this.execute({
currentStepIndex: currentStepIndex + 1, currentStepIndex: currentStepIndex + 1,
steps, steps,
payload, context,
output: updatedOutput, output: updatedOutput,
}); });
} }
@ -103,7 +100,7 @@ export class WorkflowExecutorWorkspaceService {
return await this.execute({ return await this.execute({
currentStepIndex, currentStepIndex,
steps, steps,
payload, context,
output: updatedOutput, output: updatedOutput,
attemptCount: attemptCount + 1, attemptCount: attemptCount + 1,
}); });

View File

@ -40,9 +40,11 @@ export class RunWorkflowJob {
await this.workflowExecutorWorkspaceService.execute({ await this.workflowExecutorWorkspaceService.execute({
currentStepIndex: 0, currentStepIndex: 0,
steps: workflowVersion.steps || [], steps: workflowVersion.steps || [],
payload, context: {
trigger: payload,
},
output: { output: {
steps: [], steps: {},
status: WorkflowRunStatus.RUNNING, status: WorkflowRunStatus.RUNNING,
}, },
}); });