Ver código fonte

feat(server): Initial implementation of transactional emails

Relates to #39. This is mainly a proof-of-concept with a single type of email (order receipt). The general design seems to work fine though.
Still to do:

* Revisit config to make it as simple as possible
* Implement a full default set of email types
* Figure out the best format for the templates and implement templating
Michael Bromley 7 anos atrás
pai
commit
11d56609f0

+ 1 - 0
.gitignore

@@ -390,3 +390,4 @@ dist
 server/e2e/__data__/*
 server/assets
 !server/e2e/__data__/.gitkeep
+server/test-emails

+ 44 - 0
docs/diagrams/email-component-diagram.puml

@@ -0,0 +1,44 @@
+@startuml
+!include theme.puml
+title Transactional Email Handling
+
+cloud "Vendure Server" as VS
+cloud "Email Templates" as ET
+node Event
+note right of Event
+    OrderStateTransitionEvent
+    AccountCreatedEvent
+    EmailAddressVerifiedEvent
+    etc.
+end note
+package EmailEventHandlers {
+    [OrderCompletedHandler]
+    [AccountCreatedHandler]
+}
+node EmailContext
+note right of EmailContext
+    new EmailContext({
+        type: 'order-receipt',
+        recipient: 'user@example.com',
+        data: { ... }
+    })
+end note
+node GeneratedEmail
+note right of GeneratedEmail
+    new GeneratedEmail({
+        ...EmailContext,
+        body: 'Dear Joe Smith...',
+    )
+end note
+
+VS --> Event
+Event -> OrderCompletedHandler
+Event --> AccountCreatedHandler
+OrderCompletedHandler --> EmailContext
+AccountCreatedHandler --> EmailContext
+EmailContext --> [EmailGenerator]
+[EmailGenerator] -left- ET
+[EmailGenerator] --> GeneratedEmail
+GeneratedEmail --> [EmailTransport]
+
+@enduml

+ 20 - 0
docs/diagrams/theme.puml

@@ -25,6 +25,26 @@ skinparam Class {
   BorderColor BLACK
 }
 
+skinparam Component {
+  ArrowColor LINE
+  BorderColor BLACK
+  BackgroundColor BACKGROUND
+}
+
+skinparam Note {
+  ArrowThickness 2
+  ArrowColor LINE
+  ActorBorderThickness 1
+  LifeLineBorderColor GREEN
+  BorderColor #666666
+  BackgroundColor #eeeeee
+}
+
+skinparam Interface {
+  BorderColor BLACK
+  BackgroundColor BLUE
+}
+
 skinparam Activity {
   FontColor white
   AttributeFontColor white

+ 6 - 0
server/dev-config.ts

@@ -32,6 +32,12 @@ export const devConfig: VendureConfig = {
         paymentMethodHandlers: [fakePalPaymentHandler, gripePaymentHandler],
     },
     customFields: {},
+    emailOptions: {
+        transport: {
+            type: 'file',
+            outputPath: path.join(__dirname, 'test-emails'),
+        },
+    },
     plugins: [
         new DefaultAssetServerPlugin({
             route: 'assets',

+ 5 - 0
server/e2e/config/test-config.ts

@@ -39,6 +39,11 @@ export const testConfig: VendureConfig = {
     paymentOptions: {
         paymentMethodHandlers: [],
     },
+    emailOptions: {
+        transport: {
+            type: 'none',
+        },
+    },
     assetOptions: {
         assetNamingStrategy: new DefaultAssetNamingStrategy(),
         assetStorageStrategy: new TestingAssetStorageStrategy(),

+ 2 - 0
server/package.json

@@ -44,6 +44,7 @@
     "ms": "^2.1.1",
     "mysql": "^2.16.0",
     "nanoid": "^1.2.4",
+    "nodemailer": "^4.6.8",
     "reflect-metadata": "^0.1.12",
     "rxjs": "^6.2.0",
     "sharp": "^0.20.8",
@@ -61,6 +62,7 @@
     "@types/jest": "^23.3.1",
     "@types/nanoid": "^1.2.0",
     "@types/node": "^9.3.0",
+    "@types/nodemailer": "^4.6.5",
     "@types/sharp": "^0.17.10",
     "faker": "^4.1.0",
     "graphql-request": "^1.8.2",

+ 2 - 1
server/src/app.module.ts

@@ -7,12 +7,13 @@ import * as ms from 'ms';
 import { ApiModule } from './api/api.module';
 import { ConfigModule } from './config/config.module';
 import { ConfigService } from './config/config.service';
+import { EmailModule } from './email/email.module';
 import { validateCustomFieldsConfig } from './entity/custom-entity-fields';
 import { I18nModule } from './i18n/i18n.module';
 import { I18nService } from './i18n/i18n.service';
 
 @Module({
-    imports: [ConfigModule, I18nModule, ApiModule],
+    imports: [ConfigModule, I18nModule, ApiModule, EmailModule],
 })
 export class AppModule implements NestModule {
     constructor(private configService: ConfigService, private i18nService: I18nService) {}

+ 1 - 0
server/src/config/config.service.mock.ts

@@ -27,6 +27,7 @@ export class MockConfigService implements MockClass<ConfigService> {
         promotionActions: [],
     };
     paymentOptions: {};
+    emailOptions: {};
     orderMergeOptions = {};
     orderProcessOptions = {};
     customFields = {};

+ 5 - 0
server/src/config/config.service.ts

@@ -11,6 +11,7 @@ import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import {
     AssetOptions,
     AuthOptions,
+    EmailOptions,
     getConfig,
     OrderMergeOptions,
     OrderProcessOptions,
@@ -95,6 +96,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.paymentOptions;
     }
 
+    get emailOptions(): Required<EmailOptions<any>> {
+        return this.activeConfig.emailOptions as Required<EmailOptions<any>>;
+    }
+
     get customFields(): CustomFields {
         return this.activeConfig.customFields;
     }

+ 9 - 0
server/src/config/default-config.ts

@@ -3,10 +3,12 @@ import { API_PATH, API_PORT } from 'shared/shared-constants';
 import { CustomFields } from 'shared/shared-types';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
+import { defaultEmailTypes } from '../email/default-email-types';
 
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
+import { NoopEmailGenerator } from './email/noop-email-generator';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { MergeOrdersStrategy } from './order-merge-strategy/merge-orders-strategy';
 import { UseGuestStrategy } from './order-merge-strategy/use-guest-strategy';
@@ -62,6 +64,13 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     paymentOptions: {
         paymentMethodHandlers: [],
     },
+    emailOptions: {
+        emailTypes: defaultEmailTypes,
+        generator: new NoopEmailGenerator(),
+        transport: {
+            type: 'none',
+        },
+    },
     customFields: {
         Address: [],
         Customer: [],

+ 37 - 0
server/src/config/email/email-options.ts

@@ -0,0 +1,37 @@
+import { LanguageCode } from 'shared/generated-types';
+import { Type } from 'shared/shared-types';
+
+import { EmailContext, GeneratedEmailContext } from '../../email/email-context';
+import { VendureEvent } from '../../event-bus/vendure-event';
+
+export type TemplateConfig<R = any> = {
+    subject: (data: R) => string;
+    templatePath: string;
+};
+export type TemplateByLanguage<C = any> = { defaultLanguage: TemplateConfig<C> } & {
+    [languageCode: string]: TemplateConfig<C>;
+};
+export type TemplateByChannel<C = any> = { defaultChannel: TemplateByLanguage<C> } & {
+    [channelCode: string]: TemplateByLanguage<C>;
+};
+export type CreateContextResult = {
+    recipient: string;
+    languageCode: LanguageCode;
+    channelCode: string;
+};
+export type EmailTypeConfig<T extends string, E extends VendureEvent = any> = {
+    triggerEvent: Type<E>;
+    createContext: (event: E) => CreateContextResult | undefined;
+    templates: TemplateByChannel<EmailContext<T, E>>;
+};
+export type EmailTypes<T extends string> = { [emailType in T]: EmailTypeConfig<T> };
+
+export function configEmailType<T extends string, E extends VendureEvent = VendureEvent>(
+    config: EmailTypeConfig<T, E>,
+) {
+    return config;
+}
+
+export interface EmailGenerator<T extends string = any, E extends VendureEvent = any> {
+    generate(context: EmailContext<T, E>): GeneratedEmailContext<T, E> | Promise<GeneratedEmailContext<T, E>>;
+}

+ 52 - 0
server/src/config/email/email-transport-options.ts

@@ -0,0 +1,52 @@
+/**
+ * A subset of the SMTP transport options of Nodemailer (https://nodemailer.com/smtp/)
+ */
+export interface SMTPTransportOptions {
+    type: 'smtp';
+    /** the hostname or IP address to connect to (defaults to ‘localhost’) */
+    host: string;
+    /** the port to connect to (defaults to 25 or 465) */
+    port: number;
+    /** defines authentication data */
+    auth: {
+        /** the username */
+        user: string;
+        /** then password */
+        pass: string;
+    };
+    /** defines if the connection should use SSL (if true) or not (if false) */
+    secure?: boolean;
+    /** turns off STARTTLS support if true */
+    ignoreTLS?: boolean;
+    /** forces the client to use STARTTLS. Returns an error if upgrading the connection is not possible or fails. */
+    requireTLS?: boolean;
+    /** optional hostname of the client, used for identifying to the server */
+    name?: string;
+    /** the local interface to bind to for network connections */
+    localAddress?: string;
+    /** defines preferred authentication method, e.g. ‘PLAIN’ */
+    authMethod?: string;
+}
+
+export interface SendmailTransportOptions {
+    type: 'sendmail';
+    /** path to the sendmail command (defaults to ‘sendmail’) */
+    path?: string;
+    /** either ‘windows’ or ‘unix’ (default). Forces all newlines in the output to either use Windows syntax <CR><LF> or Unix syntax <LF> */
+    newline?: string;
+}
+
+export interface FileTransportOptions {
+    type: 'file';
+    outputPath: string;
+}
+
+export interface NoopTransportOptions {
+    type: 'none';
+}
+
+export type EmailTransportOptions =
+    | SMTPTransportOptions
+    | SendmailTransportOptions
+    | FileTransportOptions
+    | NoopTransportOptions;

+ 9 - 0
server/src/config/email/noop-email-generator.ts

@@ -0,0 +1,9 @@
+import { EmailContext, GeneratedEmailContext } from '../../email/email-context';
+
+import { EmailGenerator } from './email-options';
+
+export class NoopEmailGenerator implements EmailGenerator {
+    generate(context: EmailContext): GeneratedEmailContext | Promise<GeneratedEmailContext> {
+        return new GeneratedEmailContext(context, 'email subject', 'email subject');
+    }
+}

+ 21 - 0
server/src/config/vendure-config.ts

@@ -14,6 +14,8 @@ import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strate
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { defaultConfig } from './default-config';
+import { EmailGenerator, EmailTypes } from './email/email-options';
+import { EmailTransportOptions } from './email/email-transport-options';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
 import { OrderMergeStrategy } from './order-merge-strategy/order-merge-strategy';
@@ -147,6 +149,21 @@ export interface ShippingOptions {
     shippingCalculators?: Array<ShippingCalculator<any>>;
 }
 
+export interface EmailOptions<EmailType extends string> {
+    /**
+     * Configuration for the creation and templating of each email type
+     */
+    emailTypes?: EmailTypes<EmailType>;
+    /**
+     * The EmailGenerator uses the EmailContext and template to generate the email body
+     */
+    generator?: EmailGenerator;
+    /**
+     * Configuration for the transport (email sending) method
+     */
+    transport: EmailTransportOptions;
+}
+
 export interface PaymentOptions {
     /**
      * An array of payment methods with which to process payments.
@@ -226,6 +243,10 @@ export interface VendureConfig {
      * Configures available payment processing methods.
      */
     paymentOptions: PaymentOptions;
+    /**
+     * Configures the handling of transactional emails.
+     */
+    emailOptions: EmailOptions<any>;
     /**
      * Custom Express middleware for the server.
      */

+ 32 - 0
server/src/email/default-email-types.ts

@@ -0,0 +1,32 @@
+import { LanguageCode } from 'shared/generated-types';
+import { DEFAULT_CHANNEL_CODE } from 'shared/shared-constants';
+
+import { configEmailType, EmailTypes } from '../config/email/email-options';
+
+import { OrderStateTransitionEvent } from '../event-bus/events/order-state-transition-event';
+
+export type DefaultEmailType = 'order-receipt';
+
+export const defaultEmailTypes: EmailTypes<DefaultEmailType> = {
+    'order-receipt': configEmailType({
+        triggerEvent: OrderStateTransitionEvent,
+        createContext: e => {
+            const customer = e.order.customer;
+            if (customer && e.toState === 'PaymentSettled') {
+                return {
+                    recipient: customer.emailAddress,
+                    languageCode: e.ctx.languageCode,
+                    channelCode: e.ctx.channel.code,
+                };
+            }
+        },
+        templates: {
+            defaultChannel: {
+                defaultLanguage: {
+                    subject: data => `Your order receipt`,
+                    templatePath: 'awd',
+                },
+            },
+        },
+    }),
+};

+ 40 - 0
server/src/email/email-context.ts

@@ -0,0 +1,40 @@
+import { LanguageCode } from 'shared/generated-types';
+
+import { VendureEvent } from '../event-bus/vendure-event';
+
+export class EmailContext<T extends string = any, E extends VendureEvent = any> {
+    public readonly type: T;
+    public readonly recipient: string;
+    public readonly event: E;
+    public readonly languageCode: LanguageCode;
+    public readonly channelCode: string;
+
+    constructor(options: {
+        type: T;
+        languageCode: LanguageCode;
+        channelCode: string;
+        recipient: string;
+        event: E;
+    }) {
+        const { type, recipient, event, languageCode, channelCode } = options;
+        this.type = type;
+        this.languageCode = languageCode;
+        this.channelCode = channelCode;
+        this.recipient = recipient;
+        this.event = event;
+    }
+}
+
+export class GeneratedEmailContext<T extends string = any, E extends VendureEvent = any> extends EmailContext<
+    T,
+    E
+> {
+    public readonly subject: string;
+    public readonly body: string;
+
+    constructor(context: EmailContext<T, E>, subject: string, body: string) {
+        super(context);
+        this.subject = subject;
+        this.body = body;
+    }
+}

+ 18 - 0
server/src/email/email.module.ts

@@ -0,0 +1,18 @@
+import { Module, OnModuleInit } from '@nestjs/common';
+
+import { ConfigModule } from '../config/config.module';
+import { EventBusModule } from '../event-bus/event-bus.module';
+
+import { TransactionalEmailService } from './transactional-email.service';
+
+@Module({
+    imports: [ConfigModule, EventBusModule],
+    providers: [TransactionalEmailService],
+})
+export class EmailModule implements OnModuleInit {
+    constructor(private transactionalEmailService: TransactionalEmailService) {}
+
+    async onModuleInit() {
+        await this.transactionalEmailService.init();
+    }
+}

+ 119 - 0
server/src/email/transactional-email.service.ts

@@ -0,0 +1,119 @@
+import { Injectable } from '@nestjs/common';
+import * as fs from 'fs-extra';
+import { createTransport } from 'nodemailer';
+import { default as Mail } from 'nodemailer/lib/mailer';
+import SMTPTransport from 'nodemailer/lib/smtp-transport';
+import * as path from 'path';
+import { normalizeString } from 'shared/normalize-string';
+import { assertNever } from 'shared/shared-utils';
+import * as Stream from 'stream';
+
+import { ConfigService } from '../config/config.service';
+import { EmailTypeConfig } from '../config/email/email-options';
+import { FileTransportOptions } from '../config/email/email-transport-options';
+import { EventBus } from '../event-bus/event-bus';
+import { VendureEvent } from '../event-bus/vendure-event';
+
+import { EmailContext, GeneratedEmailContext } from './email-context';
+
+export type StreamTransportInfo = {
+    envelope: {
+        from: string;
+        to: string[];
+    };
+    messageId: string;
+    message: Stream;
+};
+
+@Injectable()
+export class TransactionalEmailService {
+    constructor(private configService: ConfigService, private eventBus: EventBus) {}
+
+    async init() {
+        const { emailTypes } = this.configService.emailOptions;
+        for (const [type, config] of Object.entries(emailTypes)) {
+            this.eventBus.subscribe(config.triggerEvent, event => {
+                return this.handleEvent(type, config, event);
+            });
+        }
+        if (this.configService.emailOptions.transport.type === 'file') {
+            // ensure the configured directory exists before
+            // we attempt to write files to it
+            const emailPath = this.configService.emailOptions.transport.outputPath;
+            await fs.ensureDir(emailPath);
+        }
+    }
+
+    private async handleEvent(type: string, config: EmailTypeConfig<any>, event: VendureEvent) {
+        const { generator } = this.configService.emailOptions;
+        const contextConfig = config.createContext(event);
+        if (contextConfig) {
+            const emailContext = new EmailContext({
+                ...contextConfig,
+                type,
+                event,
+            });
+            const generatedEmailContext = await generator.generate(emailContext);
+            await this.send(generatedEmailContext);
+        }
+    }
+
+    private async send(email: GeneratedEmailContext) {
+        const { transport } = this.configService.emailOptions;
+        let transporter: Mail;
+        switch (transport.type) {
+            case 'none':
+                return;
+                break;
+            case 'file':
+                transporter = createTransport({
+                    streamTransport: true,
+                });
+                const result = await this.sendMail(email, transporter);
+                const fileName = normalizeString(
+                    `${new Date().toISOString()} ${result.envelope.to[0]} ${email.subject}`,
+                    '_',
+                );
+                const filePath = path.join(transport.outputPath, fileName);
+                await this.writeToFile(filePath, result);
+                break;
+            case 'sendmail':
+                transporter = createTransport({
+                    sendmail: true,
+                    path: transport.path,
+                });
+                await this.sendMail(email, transporter);
+                break;
+            case 'smtp':
+                transporter = createTransport({
+                    host: transport.host,
+                    port: transport.port,
+                    secure: transport.secure,
+                    auth: transport.auth.user,
+                } as SMTPTransport.Options);
+                await this.sendMail(email, transporter);
+                break;
+            default:
+                return assertNever(transport);
+        }
+    }
+
+    private async sendMail(email: GeneratedEmailContext, transporter: Mail): Promise<any> {
+        return transporter.sendMail({
+            to: email.recipient,
+            subject: email.subject,
+            html: email.body,
+        });
+    }
+
+    private async writeToFile(filePath: string, info: StreamTransportInfo): Promise<string> {
+        const writeStream = fs.createWriteStream(filePath);
+        return new Promise<string>((resolve, reject) => {
+            writeStream.on('open', () => {
+                info.message.pipe(writeStream);
+                writeStream.on('close', resolve);
+                writeStream.on('error', reject);
+            });
+        });
+    }
+}

+ 4 - 4
server/src/event-bus/event-bus.ts

@@ -11,7 +11,7 @@ export type UnsubscribeFn = () => void;
  */
 @Injectable()
 export class EventBus {
-    subscriberMap = new Map<VendureEvent, Array<EventHandler<any>>>();
+    private subscriberMap = new Map<Type<VendureEvent>, Array<EventHandler<any>>>();
 
     /**
      * Publish an event which any subscribers can react to.
@@ -32,11 +32,11 @@ export class EventBus {
      * to unsubscribe the handler from the event.
      */
     subscribe<T extends VendureEvent>(type: Type<T>, handler: EventHandler<T>): UnsubscribeFn {
-        const handlers = this.subscriberMap.get(type as any) || [];
+        const handlers = this.subscriberMap.get(type) || [];
         if (!handlers.includes(handler)) {
             handlers.push(handler);
         }
-        this.subscriberMap.set(type as any, handlers);
-        return () => this.subscriberMap.set(type as any, handlers.filter(h => h !== handler));
+        this.subscriberMap.set(type, handlers);
+        return () => this.subscriberMap.set(type, handlers.filter(h => h !== handler));
     }
 }

+ 16 - 0
server/src/event-bus/events/order-state-transition-event.ts

@@ -0,0 +1,16 @@
+import { RequestContext } from '../../api/common/request-context';
+import { Customer } from '../../entity/customer/customer.entity';
+import { Order } from '../../entity/order/order.entity';
+import { OrderState } from '../../service/helpers/order-state-machine/order-state';
+import { VendureEvent } from '../vendure-event';
+
+export class OrderStateTransitionEvent extends VendureEvent {
+    constructor(
+        public fromState: OrderState,
+        public toState: OrderState,
+        public ctx: RequestContext,
+        public order: Order,
+    ) {
+        super();
+    }
+}

+ 1 - 1
server/src/event-bus/vendure-event.ts

@@ -2,6 +2,6 @@
  * The base class for all events used by the EventBus system.
  */
 export abstract class VendureEvent {
-    createdAt = new Date();
+    public readonly createdAt = new Date();
     protected constructor() {}
 }

+ 7 - 3
server/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -1,8 +1,11 @@
 import { Injectable } from '@nestjs/common';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { FSM, StateMachineConfig, Transitions } from '../../../common/finite-state-machine';
 import { ConfigService } from '../../../config/config.service';
 import { Order } from '../../../entity/order/order.entity';
+import { EventBus } from '../../../event-bus/event-bus';
+import { OrderStateTransitionEvent } from '../../../event-bus/events/order-state-transition-event';
 import { I18nError } from '../../../i18n/i18n-error';
 
 import { OrderState, orderStateTransitions, OrderTransitionData } from './order-state';
@@ -12,7 +15,7 @@ export class OrderStateMachine {
     private readonly config: StateMachineConfig<OrderState, OrderTransitionData>;
     private readonly initialState: OrderState = 'AddingItems';
 
-    constructor(private configService: ConfigService) {
+    constructor(private configService: ConfigService, private eventBus: EventBus) {
         this.config = this.initConfig();
     }
 
@@ -25,9 +28,9 @@ export class OrderStateMachine {
         return fsm.getNextStates();
     }
 
-    async transition(order: Order, state: OrderState) {
+    async transition(ctx: RequestContext, order: Order, state: OrderState) {
         const fsm = new FSM(this.config, order.state);
-        await fsm.transitionTo(state, { order });
+        await fsm.transitionTo(state, { ctx, order });
         order.state = state;
     }
 
@@ -53,6 +56,7 @@ export class OrderStateMachine {
             data.order.active = false;
             data.order.orderPlacedAt = new Date();
         }
+        this.eventBus.publish(new OrderStateTransitionEvent(fromState, toState, data.ctx, data.order));
     }
 
     private initConfig(): StateMachineConfig<OrderState, OrderTransitionData> {

+ 2 - 0
server/src/service/helpers/order-state-machine/order-state.ts

@@ -1,3 +1,4 @@
+import { RequestContext } from '../../../api/common/request-context';
 import { Transitions } from '../../../common/finite-state-machine';
 import { Order } from '../../../entity/order/order.entity';
 
@@ -35,5 +36,6 @@ export const orderStateTransitions: Transitions<OrderState> = {
 };
 
 export interface OrderTransitionData {
+    ctx: RequestContext;
     order: Order;
 }

+ 2 - 1
server/src/service/service.module.ts

@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
 
 import { ConfigModule } from '../config/config.module';
 import { getConfig } from '../config/vendure-config';
+import { EventBusModule } from '../event-bus/event-bus.module';
 
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from './helpers/order-calculator/order-calculator';
@@ -66,7 +67,7 @@ const exportedProviders = [
  * into a format suitable for the service layer logic.
  */
 @Module({
-    imports: [ConfigModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
+    imports: [ConfigModule, EventBusModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
     providers: [
         ...exportedProviders,
         PasswordCiper,

+ 2 - 2
server/src/service/services/order.service.ts

@@ -229,7 +229,7 @@ export class OrderService {
 
     async transitionToState(ctx: RequestContext, orderId: ID, state: OrderState): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
-        await this.orderStateMachine.transition(order, state);
+        await this.orderStateMachine.transition(ctx, order, state);
         await this.connection.getRepository(Order).save(order);
         return order;
     }
@@ -258,7 +258,7 @@ export class OrderService {
 
     async addCustomerToOrder(ctx: RequestContext, orderId: ID, customer: Customer): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
-        if (order.customer) {
+        if (order.customer && !idsAreEqual(order.customer.id, customer.id)) {
             throw new I18nError(`error.order-already-has-customer`);
         }
         order.customer = customer;

+ 11 - 0
server/yarn.lock

@@ -267,6 +267,13 @@
   version "9.6.23"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.23.tgz#fc429962c1b75f32bd66214a3997f660e8434f0d"
 
+"@types/nodemailer@^4.6.5":
+  version "4.6.5"
+  resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-4.6.5.tgz#8bb799202f8cfcc8200a1c1627f6a8a74fe71da6"
+  dependencies:
+    "@types/events" "*"
+    "@types/node" "*"
+
 "@types/range-parser@*":
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.2.tgz#fa8e1ad1d474688a757140c91de6dace6f4abc8d"
@@ -4065,6 +4072,10 @@ node-pre-gyp@^0.10.0:
     semver "^5.3.0"
     tar "^4"
 
+nodemailer@^4.6.8:
+  version "4.6.8"
+  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.6.8.tgz#f82fb407828bf2e76d92acc34b823d83e774f89c"
+
 nodemon@^1.14.1:
   version "1.18.0"
   resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.18.0.tgz#44b75d5f19065c47a262d4ab21990da3b6f8feae"