vendure-ecommerce / vendure

The commerce platform with customization in its DNA.
https://www.vendure.io
Other
5.58k stars 989 forks source link

Add a function to manually send or resend (EmailEventHandler trigger) email to the UI for Order and Customer #3016

Open monrostar opened 3 weeks ago

monrostar commented 3 weeks ago

Is your feature request related to a problem? Please describe. At the moment we need to add the ability to send some emails to our customers because sometimes they may add the wrong email in their order and do not receive notification that the order needs to be paid or that the order has been paid. Due to the fact that we don't have a feature to manually send emails from admin panel, we needed a feature to allow our managers to send emails at any time just by clicking the button.

Describe the solution you'd like I reviewed the available options and discovered an interesting approach.

We can extend EmailEventHandler and add a property to specify a UI block for displaying the list of emails to resend, along with a function to generate the event from the UI. For instance, we can introduce setUiOptions and add properties like block(order, customer) and a function to generate data for these events.

This way, you can automatically integrate events into pre-prepared UI blocks. When adding a new event, you only need to create an event generator function, which can then be incorporated into Vendure core.

This enhancement can be added to the default EmailPlugin.


type UIEmailEventEntities = typeof Order | typeof Customer

/**
 * @description
 * A property used to set UI for manual event trigger of {@link EmailEventListener}
 * Represents options for configuring the UI block that allows managers to manually resend emails.
 *
 * @since 3.0.0
 * @docsCategory core plugins/EmailPlugin
 * @docsPage Email Plugin Types
 */
export interface EventUIHandlerOptions<Event extends EventWithContext, E extends UIEmailEventEntities = UIEmailEventEntities> {
    /**
     * The entity for which the UI block is being generated. This could be an Order, Customer, or any other entity.
     */
    entityType: E;

    /**
     * A function that handles the creation of an EmailEventHandler for the specified entity.
     *
     * @param ctx - The current RequestContext, providing access to the current state and settings.
     * @param injector - The dependency injector that provides access to various services.
     * @param entity - The entity for which the email event is being handled.
     * @param languageCode - (Optional) The language code for localization purposes.
     * @returns An instance of EmailEventHandler that handles the email event for the entity.
     */
    handler: (
        ctx: RequestContext,
        injector: Injector,
        entity: InstanceType<E>,
        languageCode?: LanguageCode
    ) => Event;

    /**
     * A filter function that determines whether or not the UI block should be displayed for a particular entity.
     *
     * @param ctx - The current RequestContext, providing access to the current state and settings.
     * @param injector - The dependency injector that provides access to various services.
     * @param entity - The entity for which the UI block is being generated.
     * @param languageCode - (Optional) The language code for localization purposes.
     * @returns A boolean indicating whether the UI block should be displayed.
     */
    filter: (
        ctx: RequestContext,
        injector: Injector,
        entity: InstanceType<E>,
        languageCode?: LanguageCode
    ) => boolean;
}

export class EmailEventHandler<
  T extends string = string,
  Event extends EventWithContext = EventWithContext,
> {
    private _uiOptions?: EventUIHandlerOptions<Event>
    /**
     * Sets the UI options for this EmailEventHandler, enabling the generation of a UI block for resending emails.
     *
     * @param handlerOptions - The options that define how the UI block should behave.
     */
    setUIOptions<T extends typeof Order | typeof Customer>(handlerOptions: EventUIHandlerOptions<Event, T>) {
        this._uiOptions = handlerOptions;
        return this
    }

    /**
     * Retrieves the UI options for this EmailEventHandler.
     *
     * @returns The UI options if they have been set, otherwise undefined.
     */
    get uiOptions(): EventUIHandlerOptions<Event> | undefined {
        return this._uiOptions;
    }
}

// usage example
export const orderConfirmationHandler = new EmailEventListener('order-confirmation')
    .on(OrderStateTransitionEvent)
    .filter(
        event =>
            event.toState === 'PaymentSettled' && event.fromState !== 'Modifying' && !!event.order.customer,
    )
    .setUIOptions({
        entityType: Order,
        handler: (ctx, _injector, entity, _languageCode) => {
            // For now this is just an example, in our case, we don't care about state transition and we can even skip it, still for TBD
            return new OrderStateTransitionEvent('PaymentAuthorized', 'PaymentSettled', ctx, entity)
        },
        filter: (ctx, injector, entity, languageCode) => {
            return true
        }
    })
    .loadData(async ({ event, injector }) => {
        transformOrderLineAssetUrls(event.ctx, event.order, injector);
        const shippingLines = await hydrateShippingLines(event.ctx, event.order, injector);
        return { shippingLines };
    })
    .setRecipient(event => event.order.customer!.emailAddress)
    .setFrom('{{ fromAddress }}')
    .setSubject('Order confirmation for #{{ order.code }}')
    .setTemplateVars(event => ({ order: event.order, shippingLines: event.data.shippingLines }))
    .setMockEvent(mockOrderStateTransitionEvent);

API:

export const adminSchema = gql`
    extend type Query {
        availableEmailEventsForResend(input: AvailableEmailEventsForResendInput): [EmailEvent!]!
    }

    input AvailableEmailEventsForResendInput {
        entityType: String!
    }

    type EmailEvent {
        type: String!
        entityType: String!
    }

    extend type Mutation {
        resendEmailEvent(input: ResendEmailInput!): Boolean!
    }

    input ResendEmailInput {
        type: String!
        entityType: String!
        entityId: ID!
        languageCode: String
    }
`

This way we can get a list of events with data on which events need to be added to the UI in order to be able to resend an email

Describe alternatives you've considered From the alternatives I found only ways in which you need to manually add these blocks to the interface and process for each event request for sending separately

monrostar commented 3 weeks ago

@michaelbromley

This is a feature I'm working on at the moment and I'd love to see it inside vendure core. Once my task is complete, I can create a PR to solve this problem for our company and also for the Vendure Community

monrostar commented 3 weeks ago

Also, I'd like to add an sms plugin. Absolutely the same as for EmailPlugin. I would like to have such a plugin inside Vendure core

And also I want to add a function to translate text in a template by keys using i18n. With this, it will be possible to use just 1 template for different languages

michaelbromley commented 3 weeks ago

Hi Eugene,

Thanks for this thoughtful suggestion. I agree that it would be good to support the ability to re-send emails. Here are my first thoughts based on the outline you provided. (note: I wrote this as I was reading and understanding the PR, so there is a bit of back-and-forth as I get a better idea of your concept).

General implementation

I find the naming of the "handler" function unintuitive, and based on the @return annotation maybe this is a holdover from an earlier iteration? The purpose of that function is to return an Event right? That will then be picked up by the EmailPlugin and trigger a new email send.

~But there's nothing to stop you from returning an unrelated event right? That could then potentially trigger an entirely different EmailEventHandler. I know this would be a "strange" way to use that API but for sure people will start doing so.~ I see that the TS typings enforce that the same event type be returned šŸ‘

Admin UI parts

Will the Admin UI need to have a coupling with the EmailPlugin now? Or do you plan to use a ui extension to implement the required UI parts?

Danger of re-publishing events

~Another concern I would have would be unintended side-effects from re-publishing events "synthetically". For instance in your example we might have an unrelated plugin that performs some logic on that state transition. We don't necessarily want to perform that logic a second time just because we want to re-send an email.~

I just looked at the actual implementation and realized that you already thought of this and it is not an issue šŸ‘

Naming

The setUIOptions method seems mis-named to me. As I understand it is dealing with the concept of re-sending, so maybe resendOptions could be a better choice.

handler function

As mentioned above I think this is mis-named.

filter function

The part specifically dealing with the UI is the filter, which (per your doc block) decides whether to present the option for re-sending in the UI. If so then a name like canResendFromUi would be more explicit.

But also looking at the implementation in the PR, this function is also used to prevent re-publishing the event. In which case maybe canResend is better as it applied to both UI and API usage.

idea: do we need 2 separate functions?

Thinking about these 2 functions further, I am wondering whether we really need 2 separate functions? What is we had a single function like this?

createEvent: (
        ctx: RequestContext,
        injector: Injector,
        entity: InstanceType<E>,
        languageCode?: LanguageCode,
    ) => Promise<InputEvent | false> | InputEvent | false;

then, if the given entity is not eligible for re-send for whatever reason, you return false. What are you thoughts on this approach?

Summary

In summary I like the general direction of this proposal and think we should go ahead with adding it to the EmailPlugin.

Also, I'd like to add an sms plugin. Absolutely the same as for EmailPlugin. I would like to have such a plugin inside Vendure core

And also I want to add a function to translate text in a template by keys using i18n. With this, it will be possible to use just 1 template for different languages

Please open separate issues for these. Both sound interesting, I'd like to hear more detail on what you think this would involve.

monrostar commented 3 weeks ago

Hi Eugene,

Thanks for this thoughtful suggestion. I agree that it would be good to support the ability to re-send emails. Here are my first thoughts based on the outline you provided. (note: I wrote this as I was reading and understanding the PR, so there is a bit of back-and-forth as I get a better idea of your concept).

General implementation

I find the naming of the "handler" function unintuitive, and based on the @return annotation maybe this is a holdover from an earlier iteration? The purpose of that function is to return an Event right? That will then be picked up by the EmailPlugin and trigger a new email send.

~But there's nothing to stop you from returning an unrelated event right? That could then potentially trigger an entirely different EmailEventHandler. I know this would be a "strange" way to use that API but for sure people will start doing so.~ I see that the TS typings enforce that the same event type be returned šŸ‘

Admin UI parts

Will the Admin UI need to have a coupling with the EmailPlugin now? Or do you plan to use a ui extension to implement the required UI parts?

Danger of re-publishing events

~Another concern I would have would be unintended side-effects from re-publishing events "synthetically". For instance in your example we might have an unrelated plugin that performs some logic on that state transition. We don't necessarily want to perform that logic a second time just because we want to re-send an email.~

I just looked at the actual implementation and realized that you already thought of this and it is not an issue šŸ‘

Naming

The setUIOptions method seems mis-named to me. As I understand it is dealing with the concept of re-sending, so maybe resendOptions could be a better choice.

handler function

As mentioned above I think this is mis-named.

filter function

The part specifically dealing with the UI is the filter, which (per your doc block) decides whether to present the option for re-sending in the UI. If so then a name like canResendFromUi would be more explicit.

But also looking at the implementation in the PR, this function is also used to prevent re-publishing the event. In which case maybe canResend is better as it applied to both UI and API usage.

idea: do we need 2 separate functions?

Thinking about these 2 functions further, I am wondering whether we really need 2 separate functions? What is we had a single function like this?

createEvent: (
        ctx: RequestContext,
        injector: Injector,
        entity: InstanceType<E>,
        languageCode?: LanguageCode,
    ) => Promise<InputEvent | false> | InputEvent | false;

then, if the given entity is not eligible for re-send for whatever reason, you return false. What are you thoughts on this approach?

Summary

In summary I like the general direction of this proposal and think we should go ahead with adding it to the EmailPlugin.

Also, I'd like to add an sms plugin. Absolutely the same as for EmailPlugin. I would like to have such a plugin inside Vendure core

And also I want to add a function to translate text in a template by keys using i18n. With this, it will be possible to use just 1 template for different languages

Please open separate issues for these. Both sound interesting, I'd like to hear more detail on what you think this would involve.

Yeah, you got that right. At the moment I wrote the code I have just to test this idea, I tried to do it as soon as possible.

Now that you have approved this feature I will rename all methods and variables to be more in line with this function.

Today I'm gonna create a separate issues for SMS and translations

monrostar commented 3 weeks ago

Admin UI parts

Will the Admin UI need to have a coupling with the EmailPlugin now? Or do you plan to use a ui extension to implement the required UI parts?

I'm thinking of adding UI as custom component which will be inside EmailPlugin

monrostar commented 3 weeks ago

@michaelbromley

I also thought a lot about how to make it possible to provide a special UI that could be added to pass some special data to generate an EmailEvent.

We have a plugin with which we use dynamic email templates that admins add themselves via Vendure UI Admin. This solution is ideal for creating a select component to select a template, since the event is used the same. This is not the final version yet, but I'm still working on it

This should make a very versatile function for manually sending emails or sms


export interface EventHandlerResendOptions<
    InputEvent extends EventWithContext = EventWithContext,
    Entity extends UIEmailEventEntities = UIEmailEventEntities,
    ConfArgs extends ConfigArgs = ConfigArgs,
> {
    entityType: Entity;

    label: Array<Omit<LocalizedString, '__typename'>>;

    description?: Array<Omit<LocalizedString, '__typename'>>;

    options?: ConfigurableOperationDefOptions<ConfArgs>;

    createEvent: (
        ctx: RequestContext,
        injector: Injector,
        entity: InstanceType<Entity>,
        args: ConfigArgValues<ConfArgs>,
    ) => Promise<InputEvent> | InputEvent;

    canResend: (
        ctx: RequestContext,
        injector: Injector,
        entity: InstanceType<Entity>,
    ) => Promise<boolean> | boolean;
}

   const orderConfirmationHandler = new EmailEventListener('order-confirmation')
    .on(OrderStateTransitionEvent)
    .filter(
        event =>
            event.toState === 'PaymentSettled' && event.fromState !== 'Modifying' && !!event.order.customer,
    )
    .setResendOptions({
        entityType: Order,
        label: [
            {
                value: 'Order confirmation.',
                languageCode: LanguageCode.en,
            },
        ],
        description: [
            {
                value: 'Order confirmation can be send only for specific reasons.',
                languageCode: LanguageCode.en,
            },
        ],
        options: { // **this is ConfigurableOperationDefOptions**
        // this is just an example how we can use it with custom UI component, it will be super extendable
            description: [],
            args: {
                emailEventTemplateId: {
                    type: 'ID',
                    ui: { component: 'email-event-template-list' },
                    label: [{ languageCode: LanguageCode.en, value: 'Select specific event email' }],
                },
            },
        },
        canResend: (_ctx, _injector, _entity) => {
            return true;
        },
        createEvent: (ctx, _injector, entity, _args) => {
            return new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, entity);
        },
    })