-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(email-plugin): Allow async data loading in EmailEventHandlers
Closes #184
- Loading branch information
1 parent
c187828
commit 155d429
Showing
7 changed files
with
382 additions
and
229 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T extends string = string, Event extends EventWithContext = EventWithContext> { | ||
private setRecipientFn: (event: Event) => string; | ||
private setTemplateVarsFn: SetTemplateVarsFn<Event>; | ||
private filterFns: Array<(event: Event) => boolean> = []; | ||
private configurations: EmailTemplateConfig[] = []; | ||
private defaultSubject: string; | ||
private from: string; | ||
private _mockEvent: Omit<Event, 'ctx'> | undefined; | ||
|
||
constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {} | ||
|
||
/** @internal */ | ||
get type(): T { | ||
return this.listener.type; | ||
} | ||
|
||
/** @internal */ | ||
get mockEvent(): Omit<Event, 'ctx'> | 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<T, Event> { | ||
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<T, Event> { | ||
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<Event>): EmailEventHandler<T, Event> { | ||
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<T, Event> { | ||
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<T, Event> { | ||
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<T, Event> { | ||
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<R>( | ||
loadDataFn: LoadDataFn<Event, R>, | ||
): EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>> { | ||
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<Event, 'ctx'>): EmailEventHandler<T, Event> { | ||
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<InputEvent, Data> = EventWithAsyncData<InputEvent, Data> | ||
> extends EmailEventHandler<T, Event> { | ||
constructor( | ||
public _loadDataFn: LoadDataFn<InputEvent, Data>, | ||
listener: EmailEventListener<T>, | ||
event: Type<InputEvent>, | ||
) { | ||
super(listener, event as any); | ||
} | ||
} |
Oops, something went wrong.