Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

6658 workflows add a first twenty piece email sender #6965

Merged
merged 24 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/twenty-emails/src/emails/workflow-action.email.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { BaseEmail } from 'src/components/BaseEmail';
import { Title } from 'src/components/Title';
import { CallToAction } from 'src/components/CallToAction';

type WorkflowActionEmailProps = {
dangerousHTML?: string;
title?: string;
callToAction?: {
value: string;
href: string;
};
};
export const WorkflowActionEmail = ({
dangerousHTML,
title,
callToAction,
}: WorkflowActionEmailProps) => {
return (
<BaseEmail>
{title && <Title value={title} />}
{dangerousHTML && (
<div dangerouslySetInnerHTML={{ __html: dangerousHTML }} />
)}
{callToAction && (
<CallToAction value={callToAction.value} href={callToAction.href} />
)}
</BaseEmail>
);
};
1 change: 1 addition & 0 deletions packages/twenty-emails/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './emails/delete-inactive-workspaces.email';
export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email';
export * from './emails/send-invite-link.email';
export * from './emails/workflow-action.email';
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,6 @@ export const generateWorkflowDiagram = ({
},
});

// Recursively generate flow for the next action if it exists
if (step.type !== 'CODE_ACTION') {
// processNode(action.nextAction, nodeId, xPos + 150, yPos + 100);

throw new Error('Other types as code actions are not supported yet.');
}

return nodeId;
};

Expand Down
1 change: 1 addition & 0 deletions packages/twenty-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"cache-manager-redis-yet": "^4.1.2",
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
"graphql-middleware": "^6.1.35",
"handlebars": "^4.7.8",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this library? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To resolve the data injection in the html template we provide to the payload

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it!

"jsdom": "~22.1.0",
"jwt-decode": "^4.0.0",
"langchain": "^0.2.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';

import isEmpty from 'lodash.isempty';
import { DataSource } from 'typeorm';
import { DataSource, In } from 'typeorm';

import {
Record as IRecord,
Expand Down Expand Up @@ -498,7 +498,7 @@ export class WorkspaceQueryRunnerService {
args.filter?.id?.in?.forEach((id) => assertIsValidUuid(id));

const existingRecords = await repository.find({
where: { id: { in: args.filter?.id?.in } },
where: { id: In(args.filter?.id?.in) },
});
const mappedRecords = new Map(
existingRecords.map((record) => [record.id, record]),
Expand Down Expand Up @@ -618,6 +618,19 @@ export class WorkspaceQueryRunnerService {
});
}

const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
authContext.workspace.id,
objectMetadataItem.nameSingular,
);

const existingRecords = await repository.find({
where: { id: In(args.filter?.id?.in) },
});
const mappedRecords = new Map(
existingRecords.map((record) => [record.id, record]),
);

const result = await this.execute(query, authContext.workspace.id);

const parsedResults = (
Expand All @@ -637,17 +650,21 @@ export class WorkspaceQueryRunnerService {

this.workspaceEventEmitter.emit(
`${objectMetadataItem.nameSingular}.deleted`,
parsedResults.map(
(record) =>
({
userId: authContext.user?.id,
recordId: record.id,
objectMetadata: objectMetadataItem,
properties: {
before: this.removeNestedProperties(record),
},
}) satisfies ObjectRecordDeleteEvent<any>,
),
parsedResults.map((record) => {
martmull marked this conversation as resolved.
Show resolved Hide resolved
const existingRecord = mappedRecords.get(record.id);

return {
userId: authContext.user?.id,
recordId: record.id,
objectMetadata: objectMetadataItem,
properties: {
before: this.removeNestedProperties({
...existingRecord,
...record,
}),
},
} satisfies ObjectRecordDeleteEvent<any>;
}),
authContext.workspace.id,
);

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ type WorkflowError = {
stackTrace: string;
};

export type WorkflowResult = {
data: object | null;
export type WorkflowStepResult = {
data?: object;
martmull marked this conversation as resolved.
Show resolved Hide resolved
error?: WorkflowError;
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { WorkflowCodeSettings } from 'src/modules/workflow/common/types/workflow-settings.type';

export enum WorkflowStepType {
CODE_ACTION = 'CODE_ACTION',
SEND_EMAIL_ACTION = 'SEND_EMAIL_ACTION',
}

type BaseWorkflowSettings = {
errorHandlingOptions: {
retryOnFailure: {
martmull marked this conversation as resolved.
Show resolved Hide resolved
value: boolean;
};
continueOnFailure: {
value: boolean;
};
};
};

type BaseWorkflowStep = {
id: string;
name: string;
Expand All @@ -12,7 +22,22 @@ type BaseWorkflowStep = {

export type WorkflowCodeStep = BaseWorkflowStep & {
type: WorkflowStepType.CODE_ACTION;
settings: WorkflowCodeSettings;
settings: BaseWorkflowSettings & {
serverlessFunctionId: string;
martmull marked this conversation as resolved.
Show resolved Hide resolved
};
};

export type WorkflowSendEmailStep = BaseWorkflowStep & {
type: WorkflowStepType.SEND_EMAIL_ACTION;
settings: BaseWorkflowSettings & {
subject?: string;
template?: string;
title?: string;
callToAction?: {
value: string;
href: string;
};
};
};

export type WorkflowStep = WorkflowCodeStep;
export type WorkflowStep = WorkflowCodeStep | WorkflowSendEmailStep;
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import { Injectable } from '@nestjs/common';

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 { WorkflowResult } from 'src/modules/workflow/common/types/workflow-result.type';
import { WorkflowStepResult } from 'src/modules/workflow/common/types/workflow-step-result.type';
import { WorkflowCodeStep } from 'src/modules/workflow/common/types/workflow-step.type';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-step-executor/workflow-step-executor.exception';
import { WorkflowStepExecutor } from 'src/modules/workflow/workflow-step-executor/workflow-step-executor.interface';

@Injectable()
export class CodeActionExecutor implements WorkflowStepExecutor {
export class CodeActionExecutorFactory {
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
Expand All @@ -23,7 +22,7 @@ export class CodeActionExecutor implements WorkflowStepExecutor {
}: {
step: WorkflowCodeStep;
payload?: object;
}): Promise<WorkflowResult> {
}): Promise<WorkflowStepResult> {
const { workspaceId } = this.scopedWorkspaceContextFactory.create();

if (!workspaceId) {
Expand All @@ -40,6 +39,10 @@ export class CodeActionExecutor implements WorkflowStepExecutor {
payload || {},
);

return { data: result.data, ...(result.error && { error: result.error }) };
if (result.error) {
return { error: result.error };
}

return { data: result.data || {} };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';

import { z } from 'zod';
import Handlebars from 'handlebars';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
import { WorkflowActionEmail } from 'twenty-emails';
import { render } from '@react-email/components';

import { WorkflowStepResult } from 'src/modules/workflow/common/types/workflow-step-result.type';
import { WorkflowSendEmailStep } from 'src/modules/workflow/common/types/workflow-step.type';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EmailService } from 'src/engine/integrations/email/email.service';

@Injectable()
export class SendEmailActionExecutorFactory {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not a factory to me : a factory is providing an implementation of an abstract service.
I feel we are over-using this factory naming in the backend codebase

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I felt the same, but it is done like that everywhere else in the project, so I did the same

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New naming proposal for factories:
image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workflow-step-executors instead of factories actually

private readonly logger = new Logger(SendEmailActionExecutorFactory.name);
constructor(
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
) {}

async execute({
step,
payload,
}: {
step: WorkflowSendEmailStep;
payload: {
email: string;
[key: string]: string;
};
}): Promise<WorkflowStepResult> {
try {
const emailSchema = z.string().trim().email('Invalid email');

const result = emailSchema.safeParse(payload.email);

if (!result.success) {
this.logger.warn(`Email '${payload.email}' invalid`);

return { data: { success: false } };
}

const mainText = Handlebars.compile(step.settings.template)(payload);

const window = new JSDOM('').window;
const purify = DOMPurify(window);
const safeHTML = purify.sanitize(mainText || '');

const email = WorkflowActionEmail({
dangerousHTML: safeHTML,
title: step.settings.title,
callToAction: step.settings.callToAction,
});
const html = render(email, {
pretty: true,
});
const text = render(email, {
plainText: true,
});

await this.emailService.send({
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
to: payload.email,
subject: step.settings.subject || '',
text,
html,
});

return { data: { success: true } };
} catch (error) {
return { error };
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import {
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-step-executor/workflow-step-executor.exception';
import { WorkflowStepExecutor } from 'src/modules/workflow/workflow-step-executor/workflow-step-executor.interface';
import { CodeActionExecutor } from 'src/modules/workflow/workflow-step-executor/workflow-step-executors/code-action-executor';
import { CodeActionExecutorFactory } from 'src/modules/workflow/workflow-step-executor/factories/code-action-executor.factory';
import { SendEmailActionExecutorFactory } from 'src/modules/workflow/workflow-step-executor/factories/send-email-action-executor.factory';

@Injectable()
export class WorkflowStepExecutorFactory {
constructor(private readonly codeActionExecutor: CodeActionExecutor) {}
constructor(
private readonly codeActionExecutor: CodeActionExecutorFactory,
private readonly sendEmailActionExecutor: SendEmailActionExecutorFactory,
) {}

get(stepType: WorkflowStepType): WorkflowStepExecutor {
switch (stepType) {
case WorkflowStepType.CODE_ACTION:
return this.codeActionExecutor;
case WorkflowStepType.SEND_EMAIL_ACTION:
return this.sendEmailActionExecutor;
default:
throw new WorkflowStepExecutorException(
`Workflow step executor not found for step type '${stepType}'`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { WorkflowResult } from 'src/modules/workflow/common/types/workflow-result.type';
import { WorkflowStepResult } from 'src/modules/workflow/common/types/workflow-step-result.type';
import { WorkflowStep } from 'src/modules/workflow/common/types/workflow-step.type';

export interface WorkflowStepExecutor {
Expand All @@ -8,5 +8,5 @@ export interface WorkflowStepExecutor {
}: {
step: WorkflowStep;
payload?: object;
}): Promise<WorkflowResult>;
}): Promise<WorkflowStepResult>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { Module } from '@nestjs/common';
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 { WorkflowStepExecutorFactory } from 'src/modules/workflow/workflow-step-executor/workflow-step-executor.factory';
import { CodeActionExecutor } from 'src/modules/workflow/workflow-step-executor/workflow-step-executors/code-action-executor';
import { CodeActionExecutorFactory } from 'src/modules/workflow/workflow-step-executor/factories/code-action-executor.factory';
import { SendEmailActionExecutorFactory } from 'src/modules/workflow/workflow-step-executor/factories/send-email-action-executor.factory';

@Module({
imports: [ServerlessFunctionModule],
providers: [
WorkflowStepExecutorFactory,
CodeActionExecutor,
CodeActionExecutorFactory,
SendEmailActionExecutorFactory,
ScopedWorkspaceContextFactory,
],
exports: [WorkflowStepExecutorFactory],
Expand Down
3 changes: 2 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -29402,7 +29402,7 @@ __metadata:
languageName: node
linkType: hard

"handlebars@npm:^4.7.7":
"handlebars@npm:^4.7.7, handlebars@npm:^4.7.8":
version: 4.7.8
resolution: "handlebars@npm:4.7.8"
dependencies:
Expand Down Expand Up @@ -47171,6 +47171,7 @@ __metadata:
cache-manager-redis-yet: "npm:^4.1.2"
class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch"
graphql-middleware: "npm:^6.1.35"
handlebars: "npm:^4.7.8"
jsdom: "npm:~22.1.0"
jwt-decode: "npm:^4.0.0"
langchain: "npm:^0.2.6"
Expand Down
Loading