From 155d429353dc81473d892ce07b4fb8f75777752c Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Mon, 25 Nov 2019 20:57:12 +0100 Subject: [PATCH] feat(email-plugin): Allow async data loading in EmailEventHandlers Closes #184 --- packages/email-plugin/index.ts | 2 + packages/email-plugin/src/dev-mailbox.ts | 2 +- packages/email-plugin/src/event-handler.ts | 262 ++++++++++++++++++++ packages/email-plugin/src/event-listener.ts | 201 +-------------- packages/email-plugin/src/plugin.spec.ts | 59 ++++- packages/email-plugin/src/plugin.ts | 56 +++-- packages/email-plugin/src/types.ts | 29 ++- 7 files changed, 382 insertions(+), 229 deletions(-) create mode 100644 packages/email-plugin/src/event-handler.ts diff --git a/packages/email-plugin/index.ts b/packages/email-plugin/index.ts index c6d33322f5..de4bbae76f 100644 --- a/packages/email-plugin/index.ts +++ b/packages/email-plugin/index.ts @@ -1,5 +1,7 @@ export * from './src/default-email-handlers'; export * from './src/email-sender'; +export * from './src/event-handler'; +export * from './src/event-listener'; export * from './src/handlebars-mjml-generator'; export * from './src/noop-email-generator'; export * from './src/plugin'; diff --git a/packages/email-plugin/src/dev-mailbox.ts b/packages/email-plugin/src/dev-mailbox.ts index bb37b99d18..8be02e5223 100644 --- a/packages/email-plugin/src/dev-mailbox.ts +++ b/packages/email-plugin/src/dev-mailbox.ts @@ -5,7 +5,7 @@ import fs from 'fs-extra'; import http from 'http'; import path from 'path'; -import { EmailEventHandler } from './event-listener'; +import { EmailEventHandler } from './event-handler'; import { EmailPluginDevModeOptions, EventWithContext } from './types'; /** diff --git a/packages/email-plugin/src/event-handler.ts b/packages/email-plugin/src/event-handler.ts new file mode 100644 index 0000000000..a48a94bf3f --- /dev/null +++ b/packages/email-plugin/src/event-handler.ts @@ -0,0 +1,262 @@ +import { LanguageCode } from '../../common/lib/generated-types'; +import { Omit } from '../../common/lib/omit'; +import { Type } from '../../common/lib/shared-types'; + +import { EmailEventListener, EmailTemplateConfig, SetTemplateVarsFn } from './event-listener'; +import { EventWithAsyncData, EventWithContext, LoadDataFn } from './types'; + +/** + * @description + * The EmailEventHandler defines how the EmailPlugin will respond to a given event. + * + * A handler is created by creating a new {@link EmailEventListener} and calling the `.on()` method + * to specify which event to respond to. + * + * @example + * ```ts + * const confirmationHandler = new EmailEventListener('order-confirmation') + * .on(OrderStateTransitionEvent) + * .filter(event => event.toState === 'PaymentSettled') + * .setRecipient(event => event.order.customer.emailAddress) + * .setSubject(`Order confirmation for #{{ order.code }}`) + * .setTemplateVars(event => ({ order: event.order })); + * ``` + * + * This example creates a handler which listens for the `OrderStateTransitionEvent` and if the Order has + * transitioned to the `'PaymentSettled'` state, it will generate and send an email. + * + * ## Handling other languages + * + * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above) + * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used + * to defined the subject and body template for specific language and channel combinations. + * + * @example + * ```ts + * const extendedConfirmationHandler = confirmationHandler + * .addTemplate({ + * channelCode: 'default', + * languageCode: LanguageCode.de, + * templateFile: 'body.de.hbs', + * subject: 'Bestellbestätigung für #{{ order.code }}', + * }) + * ``` + * + * @docsCategory EmailPlugin + */ +export class EmailEventHandler { + private setRecipientFn: (event: Event) => string; + private setTemplateVarsFn: SetTemplateVarsFn; + private filterFns: Array<(event: Event) => boolean> = []; + private configurations: EmailTemplateConfig[] = []; + private defaultSubject: string; + private from: string; + private _mockEvent: Omit | undefined; + + constructor(public listener: EmailEventListener, public event: Type) {} + + /** @internal */ + get type(): T { + return this.listener.type; + } + + /** @internal */ + get mockEvent(): Omit | undefined { + return this._mockEvent; + } + + /** + * @description + * Defines a predicate function which is used to determine whether the event will trigger an email. + * Multiple filter functions may be defined. + */ + filter(filterFn: (event: Event) => boolean): EmailEventHandler { + this.filterFns.push(filterFn); + return this; + } + + /** + * @description + * A function which defines how the recipient email address should be extracted from the incoming event. + */ + setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler { + this.setRecipientFn = setRecipientFn; + return this; + } + + /** + * @description + * A function which returns an object hash of variables which will be made available to the Handlebars template + * and subject line for interpolation. + */ + setTemplateVars(templateVarsFn: SetTemplateVarsFn): EmailEventHandler { + this.setTemplateVarsFn = templateVarsFn; + return this; + } + + /** + * @description + * Sets the default subject of the email. The subject string may use Handlebars variables defined by the + * setTemplateVars() method. + */ + setSubject(defaultSubject: string): EmailEventHandler { + this.defaultSubject = defaultSubject; + return this; + } + + /** + * @description + * Sets the default from field of the email. The from string may use Handlebars variables defined by the + * setTemplateVars() method. + */ + setFrom(from: string): EmailEventHandler { + this.from = from; + return this; + } + + /** + * @description + * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific + * templates for channels or languageCodes other than the default. + */ + addTemplate(config: EmailTemplateConfig): EmailEventHandler { + this.configurations.push(config); + return this; + } + + /** + * @description + * Allows data to be loaded asynchronously which can then be used as template variables. + * The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an + * `inject()` function which can be used to inject any of the providers exported + * by the {@link PluginCommonModule}. The return value of the `loadDataFn` will be + * added to the `event` as the `data` property. + * + * @example + * ```TypeScript + * new EmailEventListener('order-confirmation') + * .on(OrderStateTransitionEvent) + * .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer) + * .loadData(({ event, inject}) => { + * const orderService = inject(OrderService); + * return orderService.getOrderPayments(event.order.id); + * }) + * .setTemplateVars(event => ({ + * order: event.order, + * payments: event.data, + * })); + * ``` + */ + loadData( + loadDataFn: LoadDataFn, + ): EmailEventHandlerWithAsyncData> { + return new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event); + } + + /** + * @description + * Used internally by the EmailPlugin to handle incoming events. + * + * @internal + */ + async handle( + event: Event, + globals: { [key: string]: any } = {}, + ): Promise< + | { from: string; recipient: string; templateVars: any; subject: string; templateFile: string } + | undefined + > { + for (const filterFn of this.filterFns) { + if (!filterFn(event)) { + return; + } + } + if (!this.setRecipientFn) { + throw new Error( + `No setRecipientFn has been defined. ` + + `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`, + ); + } + if (this.from === undefined) { + throw new Error( + `No from field has been defined. ` + + `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`, + ); + } + const { ctx } = event; + const configuration = this.getBestConfiguration(ctx.channel.code, ctx.languageCode); + const subject = configuration ? configuration.subject : this.defaultSubject; + if (subject == null) { + throw new Error( + `No subject field has been defined. ` + + `Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`, + ); + } + const recipient = this.setRecipientFn(event); + const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {}; + return { + recipient, + from: this.from, + templateVars: { ...globals, ...templateVars }, + subject, + templateFile: configuration ? configuration.templateFile : 'body.hbs', + }; + } + + /** + * @description + * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails + * from this handler, which is useful when developing the email templates. + */ + setMockEvent(event: Omit): EmailEventHandler { + this._mockEvent = event; + return this; + } + + private getBestConfiguration( + channelCode: string, + languageCode: LanguageCode, + ): EmailTemplateConfig | undefined { + if (this.configurations.length === 0) { + return; + } + const exactMatch = this.configurations.find(c => { + return ( + (c.channelCode === channelCode || c.channelCode === 'default') && + c.languageCode === languageCode + ); + }); + if (exactMatch) { + return exactMatch; + } + const channelMatch = this.configurations.find( + c => c.channelCode === channelCode && c.languageCode === 'default', + ); + if (channelMatch) { + return channelMatch; + } + return; + } +} + +/** + * @description + * Identical to the {@link EmailEventHandler} but with a `data` property added to the `event` based on the result + * of the `.loadData()` function. + * + * @docsCategory EmailPlugin + */ +export class EmailEventHandlerWithAsyncData< + Data, + T extends string = string, + InputEvent extends EventWithContext = EventWithContext, + Event extends EventWithAsyncData = EventWithAsyncData +> extends EmailEventHandler { + constructor( + public _loadDataFn: LoadDataFn, + listener: EmailEventListener, + event: Type, + ) { + super(listener, event as any); + } +} diff --git a/packages/email-plugin/src/event-listener.ts b/packages/email-plugin/src/event-listener.ts index 0fde0b0699..fbc6a9eeed 100644 --- a/packages/email-plugin/src/event-listener.ts +++ b/packages/email-plugin/src/event-listener.ts @@ -1,8 +1,8 @@ import { LanguageCode } from '@vendure/common/lib/generated-types'; -import { Omit } from '@vendure/common/lib/omit'; import { Type } from '@vendure/common/lib/shared-types'; -import { EmailDetails, EventWithContext } from './types'; +import { EmailEventHandler } from './event-handler'; +import { EventWithContext } from './types'; /** * @description @@ -71,200 +71,3 @@ export class EmailEventListener { return new EmailEventHandler(this, event); } } - -/** - * @description - * The EmailEventHandler defines how the EmailPlugin will respond to a given event. - * - * A handler is created by creating a new {@link EmailEventListener} and calling the `.on()` method - * to specify which event to respond to. - * - * @example - * ```ts - * const confirmationHandler = new EmailEventListener('order-confirmation') - * .on(OrderStateTransitionEvent) - * .filter(event => event.toState === 'PaymentSettled') - * .setRecipient(event => event.order.customer.emailAddress) - * .setSubject(`Order confirmation for #{{ order.code }}`) - * .setTemplateVars(event => ({ order: event.order })); - * ``` - * - * This example creates a handler which listens for the `OrderStateTransitionEvent` and if the Order has - * transitioned to the `'PaymentSettled'` state, it will generate and send an email. - * - * ## Handling other languages - * - * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above) - * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used - * to defined the subject and body template for specific language and channel combinations. - * - * @example - * ```ts - * const extendedConfirmationHandler = confirmationHandler - * .addTemplate({ - * channelCode: 'default', - * languageCode: LanguageCode.de, - * templateFile: 'body.de.hbs', - * subject: 'Bestellbestätigung für #{{ order.code }}', - * }) - * ``` - * - * @docsCategory EmailPlugin - */ -export class EmailEventHandler { - private setRecipientFn: (event: Event) => string; - private setTemplateVarsFn: SetTemplateVarsFn; - private filterFns: Array<(event: Event) => boolean> = []; - private configurations: EmailTemplateConfig[] = []; - private defaultSubject: string; - private from: string; - private _mockEvent: Omit | undefined; - - constructor(public listener: EmailEventListener, public event: Type) {} - - /** @internal */ - get type(): T { - return this.listener.type; - } - - /** @internal */ - get mockEvent(): Omit | undefined { - return this._mockEvent; - } - - /** - * @description - * Defines a predicate function which is used to determine whether the event will trigger an email. - * Multiple filter functions may be defined. - */ - filter(filterFn: (event: Event) => boolean): EmailEventHandler { - this.filterFns.push(filterFn); - return this; - } - - /** - * @description - * A function which defines how the recipient email address should be extracted from the incoming event. - */ - setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler { - this.setRecipientFn = setRecipientFn; - return this; - } - - /** - * @description - * A function which returns an object hash of variables which will be made available to the Handlebars template - * and subject line for interpolation. - */ - setTemplateVars(templateVarsFn: SetTemplateVarsFn): EmailEventHandler { - this.setTemplateVarsFn = templateVarsFn; - return this; - } - - /** - * @description - * Sets the default subject of the email. The subject string may use Handlebars variables defined by the - * setTemplateVars() method. - */ - setSubject(defaultSubject: string): EmailEventHandler { - this.defaultSubject = defaultSubject; - return this; - } - - /** - * @description - * Sets the default from field of the email. The from string may use Handlebars variables defined by the - * setTemplateVars() method. - */ - setFrom(from: string): EmailEventHandler { - this.from = from; - return this; - } - - /** - * @description - * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific - * templates for channels or languageCodes other than the default. - */ - addTemplate(config: EmailTemplateConfig): EmailEventHandler { - this.configurations.push(config); - return this; - } - - /** - * @description - * Used internally by the EmailPlugin to handle incoming events. - * - * @internal - */ - handle( - event: Event, - globals: { [key: string]: any } = {}, - ): - | { from: string; recipient: string; templateVars: any; subject: string; templateFile: string } - | undefined { - for (const filterFn of this.filterFns) { - if (!filterFn(event)) { - return; - } - } - if (!this.setRecipientFn) { - throw new Error( - `No setRecipientFn has been defined. ` + - `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`, - ); - } - if (this.from === undefined) { - throw new Error( - `No from field has been defined. ` + - `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`, - ); - } - const { ctx } = event; - const configuration = this.getBestConfiguration(ctx.channel.code, ctx.languageCode); - const recipient = this.setRecipientFn(event); - const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {}; - return { - recipient, - from: this.from, - templateVars: { ...globals, ...templateVars }, - subject: configuration ? configuration.subject : this.defaultSubject, - templateFile: configuration ? configuration.templateFile : 'body.hbs', - }; - } - - /** - * @description - * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails - * from this handler, which is useful when developing the email templates. - */ - setMockEvent(event: Omit): EmailEventHandler { - this._mockEvent = event; - return this; - } - - private getBestConfiguration( - channelCode: string, - languageCode: LanguageCode, - ): EmailTemplateConfig | undefined { - if (this.configurations.length === 0) { - return; - } - const exactMatch = this.configurations.find(c => { - return ( - (c.channelCode === channelCode || c.channelCode === 'default') && - c.languageCode === languageCode - ); - }); - if (exactMatch) { - return exactMatch; - } - const channelMatch = this.configurations.find( - c => c.channelCode === channelCode && c.languageCode === 'default', - ); - if (channelMatch) { - return channelMatch; - } - return; - } -} diff --git a/packages/email-plugin/src/plugin.spec.ts b/packages/email-plugin/src/plugin.spec.ts index 2c89cc4a1a..b473a4239c 100644 --- a/packages/email-plugin/src/plugin.spec.ts +++ b/packages/email-plugin/src/plugin.spec.ts @@ -1,16 +1,20 @@ /* tslint:disable:no-non-null-assertion */ import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants'; +import { + EventBus, + LanguageCode, + Order, + OrderStateTransitionEvent, + PluginCommonModule, + VendureEvent, +} from '@vendure/core'; import path from 'path'; -import { LanguageCode } from '../../common/lib/generated-types'; -import { DEFAULT_CHANNEL_CODE } from '../../common/lib/shared-constants'; -import { Order } from '../../core/dist/entity/order/order.entity'; -import { EventBus } from '../../core/dist/event-bus/event-bus'; -import { OrderStateTransitionEvent } from '../../core/dist/event-bus/events/order-state-transition-event'; -import { VendureEvent } from '../../core/dist/event-bus/vendure-event'; - import { orderConfirmationHandler } from './default-email-handlers'; -import { EmailEventHandler, EmailEventListener } from './event-listener'; +import { EmailEventHandler } from './event-handler'; +import { EmailEventListener } from './event-listener'; import { EmailPlugin } from './plugin'; import { EmailPluginOptions } from './types'; @@ -26,6 +30,10 @@ describe('EmailPlugin', () => { onSend = jest.fn(); const module = await Test.createTestingModule({ imports: [ + TypeOrmModule.forRoot({ + type: 'sqljs', + }), + PluginCommonModule, EmailPlugin.init({ templatePath: path.join(__dirname, '../test-templates'), transport: { @@ -36,6 +44,7 @@ describe('EmailPlugin', () => { ...options, }), ], + providers: [MockService], }).compile(); plugin = module.get(EmailPlugin); @@ -234,6 +243,34 @@ describe('EmailPlugin', () => { }); }); + describe('loadData', () => { + it('loads async data', async () => { + const handler = new EmailEventListener('test') + .on(MockEvent) + .loadData(async ({ inject }) => { + const service = inject(MockService); + return service.someAsyncMethod(); + }) + .setFrom('"test from" ') + .setSubject('Hello, {{ testData }}!') + .setRecipient(() => 'test@test.com') + .setTemplateVars(event => ({ testData: event.data })); + + const module = await initPluginWithHandlers([handler]); + + eventBus.publish( + new MockEvent( + { channel: { code: DEFAULT_CHANNEL_CODE }, languageCode: LanguageCode.en }, + true, + ), + ); + await pause(); + + expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!'); + await module.close(); + }); + }); + describe('orderConfirmationHandler', () => { let module: TestingModule; beforeEach(async () => { @@ -299,3 +336,9 @@ class MockEvent extends VendureEvent { super(); } } + +class MockService { + someAsyncMethod() { + return Promise.resolve('loaded data'); + } +} diff --git a/packages/email-plugin/src/plugin.ts b/packages/email-plugin/src/plugin.ts index cb84b47316..0de44c6ed9 100644 --- a/packages/email-plugin/src/plugin.ts +++ b/packages/email-plugin/src/plugin.ts @@ -1,26 +1,30 @@ +import { ModuleRef } from '@nestjs/core'; +import { InjectConnection } from '@nestjs/typeorm'; import { createProxyHandler, EventBus, - EventBusModule, InternalServerError, Logger, OnVendureBootstrap, OnVendureClose, + PluginCommonModule, RuntimeVendureConfig, Type, VendurePlugin, } from '@vendure/core'; import fs from 'fs-extra'; +import { Connection } from 'typeorm'; import { DevMailbox } from './dev-mailbox'; import { EmailSender } from './email-sender'; -import { EmailEventHandler } from './event-listener'; +import { EmailEventHandler, EmailEventHandlerWithAsyncData } from './event-handler'; import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator'; import { TemplateLoader } from './template-loader'; import { EmailPluginDevModeOptions, EmailPluginOptions, EmailTransportOptions, + EventWithAsyncData, EventWithContext, } from './types'; @@ -133,7 +137,7 @@ import { * @docsCategory EmailPlugin */ @VendurePlugin({ - imports: [EventBusModule], + imports: [PluginCommonModule], configuration: config => EmailPlugin.configure(config), }) export class EmailPlugin implements OnVendureBootstrap, OnVendureClose { @@ -145,7 +149,11 @@ export class EmailPlugin implements OnVendureBootstrap, OnVendureClose { private devMailbox: DevMailbox | undefined; /** @internal */ - constructor(private eventBus: EventBus) {} + constructor( + private eventBus: EventBus, + @InjectConnection() private connection: Connection, + private moduleRef: ModuleRef, + ) {} /** * Set the plugin options. @@ -222,22 +230,36 @@ export class EmailPlugin implements OnVendureBootstrap, OnVendureClose { } } - private async handleEvent(handler: EmailEventHandler, event: EventWithContext) { + private async handleEvent( + handler: EmailEventHandler | EmailEventHandlerWithAsyncData, + event: EventWithContext, + ) { Logger.debug(`Handling event "${handler.type}"`, 'EmailPlugin'); const { type } = handler; - const result = handler.handle(event, EmailPlugin.options.globalTemplateVars); - if (!result) { - return; + try { + if (handler instanceof EmailEventHandlerWithAsyncData) { + (event as EventWithAsyncData).data = await handler._loadDataFn({ + event, + connection: this.connection, + inject: t => this.moduleRef.get(t, { strict: false }), + }); + } + const result = await handler.handle(event as any, EmailPlugin.options.globalTemplateVars); + if (!result) { + return; + } + const bodySource = await this.templateLoader.loadTemplate(type, result.templateFile); + const generated = await this.generator.generate( + result.from, + result.subject, + bodySource, + result.templateVars, + ); + const emailDetails = { ...generated, recipient: result.recipient }; + await this.emailSender.send(emailDetails, this.transport); + } catch (e) { + Logger.error(e.message, 'EmailPlugin', e.stack); } - const bodySource = await this.templateLoader.loadTemplate(type, result.templateFile); - const generated = await this.generator.generate( - result.from, - result.subject, - bodySource, - result.templateVars, - ); - const emailDetails = { ...generated, recipient: result.recipient }; - await this.emailSender.send(emailDetails, this.transport); } } diff --git a/packages/email-plugin/src/types.ts b/packages/email-plugin/src/types.ts index ac6950808e..f52a5e54b0 100644 --- a/packages/email-plugin/src/types.ts +++ b/packages/email-plugin/src/types.ts @@ -1,9 +1,8 @@ -import { LanguageCode } from '@vendure/common/lib/generated-types'; import { Omit } from '@vendure/common/lib/omit'; -import { Type } from '@vendure/common/lib/shared-types'; -import { RequestContext, VendureEvent } from '@vendure/core'; +import { RequestContext, Type, VendureEvent } from '@vendure/core'; +import { Connection } from 'typeorm'; -import { EmailEventHandler } from './event-listener'; +import { EmailEventHandler } from './event-handler'; /** * @description @@ -16,6 +15,16 @@ import { EmailEventHandler } from './event-listener'; */ export type EventWithContext = VendureEvent & { ctx: RequestContext }; +/** + * @description + * A VendureEvent with a {@link RequestContext} and a `data` property which contains the + * value resolved from the {@link EmailEventHandler}`.loadData()` callback. + * + * @docsCategory EmailPlugin + * @docsPage Email Plugin Types + */ +export type EventWithAsyncData = Event & { data: R }; + /** * @description * Configuration for the EmailPlugin. @@ -251,3 +260,15 @@ export interface EmailGenerator; } + +/** + * @description + * A function used to load async data for use by an {@link EmailEventHandler}. + * + * @docsCategory EmailPlugin + */ +export type LoadDataFn = (context: { + event: Event; + connection: Connection; + inject: (type: Type) => T; +}) => Promise;