소스 검색

feat(core): Make Customers ChannelAware

* feat(core): Make Customers ChannelAware

* test(core): Fix unit tests for ChannelAware Customers

* fix(core): Finalize ChannelAware Customers implementation

* fix(core): Implement review comments

Co-authored-by: hendrikdepauw <hendrik@advantitge.com>
Hendrik Depauw 5 년 전
부모
커밋
0f7347336f
25개의 변경된 파일780개의 추가작업 그리고 110개의 파일을 삭제
  1. 487 0
      packages/core/e2e/customer-channel.e2e-spec.ts
  2. 10 10
      packages/core/e2e/customer.e2e-spec.ts
  3. 44 1
      packages/core/src/api/middleware/auth-guard.ts
  4. 15 6
      packages/core/src/api/resolvers/admin/customer.resolver.ts
  5. 1 1
      packages/core/src/api/resolvers/entity/customer-entity.resolver.ts
  6. 1 1
      packages/core/src/api/resolvers/entity/customer-group-entity.resolver.ts
  7. 3 3
      packages/core/src/api/resolvers/shop/shop-auth.resolver.ts
  8. 2 2
      packages/core/src/api/resolvers/shop/shop-customer.resolver.ts
  9. 1 1
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  10. 2 1
      packages/core/src/config/promotion/conditions/contains-products-condition.ts
  11. 4 3
      packages/core/src/config/promotion/conditions/customer-group-condition.ts
  12. 2 1
      packages/core/src/config/promotion/conditions/has-facet-values-condition.ts
  13. 3 1
      packages/core/src/config/promotion/conditions/min-order-amount-condition.ts
  14. 4 2
      packages/core/src/config/promotion/promotion-condition.ts
  15. 1 0
      packages/core/src/config/session-cache/session-cache-strategy.ts
  16. 12 7
      packages/core/src/entity/customer/customer.entity.ts
  17. 3 2
      packages/core/src/entity/promotion/promotion.entity.ts
  18. 8 1
      packages/core/src/entity/session/session.entity.ts
  19. 16 10
      packages/core/src/service/helpers/external-authentication/external-authentication.service.ts
  20. 2 2
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  21. 14 10
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  22. 24 9
      packages/core/src/service/services/customer-group.service.ts
  23. 103 33
      packages/core/src/service/services/customer.service.ts
  24. 3 3
      packages/core/src/service/services/order.service.ts
  25. 15 0
      packages/core/src/service/services/session.service.ts

+ 487 - 0
packages/core/e2e/customer-channel.e2e-spec.ts

@@ -0,0 +1,487 @@
+/* tslint:disable:no-non-null-assertion */
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { CUSTOMER_FRAGMENT } from './graphql/fragments';
+import {
+    AddCustomersToGroup,
+    CreateAddress,
+    CreateChannel,
+    CreateCustomer,
+    CreateCustomerGroup,
+    CurrencyCode,
+    DeleteCustomer,
+    DeleteCustomerAddress,
+    GetCustomerGroup,
+    GetCustomerList,
+    LanguageCode,
+    Me,
+    RemoveCustomersFromGroup,
+    UpdateAddress,
+    UpdateCustomer,
+} from './graphql/generated-e2e-admin-types';
+import { Register } from './graphql/generated-e2e-shop-types';
+import {
+    CREATE_CHANNEL,
+    CREATE_CUSTOMER_GROUP,
+    CUSTOMER_GROUP_FRAGMENT,
+    GET_CUSTOMER_LIST,
+    ME,
+    REMOVE_CUSTOMERS_FROM_GROUP,
+} from './graphql/shared-definitions';
+import { DELETE_ADDRESS, REGISTER_ACCOUNT } from './graphql/shop-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+describe('ChannelAware Customers', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
+    const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+    let firstCustomer: GetCustomerList.Items;
+    let secondCustomer: GetCustomerList.Items;
+    let thirdCustomer: GetCustomerList.Items;
+    const numberOfCustomers = 3;
+    let customerGroupId: string;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: numberOfCustomers,
+        });
+        await adminClient.asSuperAdmin();
+
+        const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            GET_CUSTOMER_LIST,
+            {
+                options: { take: numberOfCustomers },
+            },
+        );
+        firstCustomer = customers.items[0];
+        secondCustomer = customers.items[1];
+        thirdCustomer = customers.items[2];
+
+        await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
+            input: {
+                code: 'second-channel',
+                token: SECOND_CHANNEL_TOKEN,
+                defaultLanguageCode: LanguageCode.en,
+                currencyCode: CurrencyCode.GBP,
+                pricesIncludeTax: true,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_1',
+            },
+        });
+
+        const { createCustomerGroup } = await adminClient.query<
+            CreateCustomerGroup.Mutation,
+            CreateCustomerGroup.Variables
+        >(CREATE_CUSTOMER_GROUP, {
+            input: {
+                name: 'TestGroup',
+            },
+        });
+        customerGroupId = createCustomerGroup.id;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('Address manipulation', () => {
+        it(
+            'throws when updating address from customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<UpdateAddress.Mutation, UpdateAddress.Variables>(UPDATE_ADDRESS, {
+                    input: {
+                        id: 'T_1',
+                        streetLine1: 'Dummy street',
+                    },
+                });
+            }, `No Address with the id '1' could be found`),
+        );
+
+        it(
+            'throws when creating address for customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<CreateAddress.Mutation, CreateAddress.Variables>(CREATE_ADDRESS, {
+                    id: firstCustomer.id,
+                    input: {
+                        streetLine1: 'Dummy street',
+                        countryCode: 'BE',
+                    },
+                });
+            }, `No Customer with the id '1' could be found`),
+        );
+
+        it(
+            'throws when deleting address from customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<DeleteCustomerAddress.Mutation, DeleteCustomerAddress.Variables>(
+                    DELETE_ADDRESS,
+                    {
+                        id: 'T_1',
+                    },
+                );
+            }, `No Address with the id '1' could be found`),
+        );
+    });
+
+    describe('Customer manipulation', () => {
+        it(
+            'throws when deleting customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<DeleteCustomer.Mutation, DeleteCustomer.Variables>(DELETE_CUSTOMER, {
+                    id: firstCustomer.id,
+                });
+            }, `No Customer with the id '1' could be found`),
+        );
+
+        it(
+            'throws when updating customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<UpdateCustomer.Mutation, UpdateCustomer.Variables>(UPDATE_CUSTOMER, {
+                    input: {
+                        id: firstCustomer.id,
+                        firstName: 'John',
+                        lastName: 'Doe',
+                    },
+                });
+            }, `No Customer with the id '1' could be found`),
+        );
+
+        it('creates customers on current and default channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<CreateCustomer.Mutation, CreateCustomer.Variables>(CREATE_CUSTOMER, {
+                input: {
+                    firstName: 'John',
+                    lastName: 'Doe',
+                    emailAddress: 'john.doe@test.com',
+                },
+            });
+            const customersSecondChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const customersDefaultChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+
+            expect(customersSecondChannel.customers.totalItems).toBe(1);
+            expect(customersDefaultChannel.customers.totalItems).toBe(numberOfCustomers + 1);
+        });
+
+        it('only shows customers from current channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(1);
+        });
+
+        it('shows all customers on default channel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(numberOfCustomers + 1);
+        });
+
+        it('brings customer to current channel when creating with existing emailAddress', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            let customersDefaultChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            let customersSecondChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            expect(customersDefaultChannel.customers.items.map(customer => customer.emailAddress)).toContain(
+                firstCustomer.emailAddress,
+            );
+            expect(
+                customersSecondChannel.customers.items.map(customer => customer.emailAddress),
+            ).not.toContain(firstCustomer.emailAddress);
+
+            await adminClient.query<CreateCustomer.Mutation, CreateCustomer.Variables>(CREATE_CUSTOMER, {
+                input: {
+                    firstName: firstCustomer.firstName + '_new',
+                    lastName: firstCustomer.lastName + '_new',
+                    emailAddress: firstCustomer.emailAddress,
+                },
+            });
+
+            customersSecondChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            customersDefaultChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            const firstCustomerOnNewChannel = customersSecondChannel.customers.items.find(
+                customer => customer.emailAddress === firstCustomer.emailAddress,
+            );
+            const firstCustomerOnDefaultChannel = customersDefaultChannel.customers.items.find(
+                customer => customer.emailAddress === firstCustomer.emailAddress,
+            );
+
+            expect(firstCustomerOnNewChannel).not.toBeNull();
+            expect(firstCustomerOnNewChannel?.emailAddress).toBe(firstCustomer.emailAddress);
+            expect(firstCustomerOnNewChannel?.firstName).toBe(firstCustomer.firstName + '_new');
+            expect(firstCustomerOnNewChannel?.lastName).toBe(firstCustomer.lastName + '_new');
+
+            expect(firstCustomerOnDefaultChannel).not.toBeNull();
+            expect(firstCustomerOnDefaultChannel?.emailAddress).toBe(firstCustomer.emailAddress);
+            expect(firstCustomerOnDefaultChannel?.firstName).toBe(firstCustomer.firstName + '_new');
+            expect(firstCustomerOnDefaultChannel?.lastName).toBe(firstCustomer.lastName + '_new');
+        });
+    });
+
+    describe('Shop API', () => {
+        it('assigns authenticated customers to the channels they visit', async () => {
+            shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await shopClient.asUserWithCredentials(secondCustomer.emailAddress, 'test');
+            await shopClient.query<Me.Query>(ME);
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(3);
+            expect(customers.items.map(customer => customer.emailAddress)).toContain(
+                secondCustomer.emailAddress,
+            );
+        });
+
+        it('assigns newly registered customers to channel', async () => {
+            shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await shopClient.asAnonymousUser();
+            await shopClient.query<Register.Mutation, Register.Variables>(REGISTER_ACCOUNT, {
+                input: {
+                    emailAddress: 'john.doe.2@test.com',
+                },
+            });
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(4);
+            expect(customers.items.map(customer => customer.emailAddress)).toContain('john.doe.2@test.com');
+        });
+    });
+
+    describe('Customergroup manipulation', () => {
+        it('does not add a customer from another channel to customerGroup', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<AddCustomersToGroup.Mutation, AddCustomersToGroup.Variables>(
+                ADD_CUSTOMERS_TO_GROUP,
+                {
+                    groupId: customerGroupId,
+                    customerIds: [thirdCustomer.id],
+                },
+            );
+
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { customerGroup } = await adminClient.query<
+                GetCustomerGroup.Query,
+                GetCustomerGroup.Variables
+            >(GET_CUSTOMER_GROUP, {
+                id: customerGroupId,
+            });
+            expect(customerGroup!.customers.totalItems).toBe(0);
+        });
+
+        it('only shows customers from current channel in customerGroup', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            await adminClient.query<AddCustomersToGroup.Mutation, AddCustomersToGroup.Variables>(
+                ADD_CUSTOMERS_TO_GROUP,
+                {
+                    groupId: customerGroupId,
+                    customerIds: [secondCustomer.id, thirdCustomer.id],
+                },
+            );
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customerGroup } = await adminClient.query<
+                GetCustomerGroup.Query,
+                GetCustomerGroup.Variables
+            >(GET_CUSTOMER_GROUP, {
+                id: customerGroupId,
+            });
+            expect(customerGroup!.customers.totalItems).toBe(1);
+            expect(customerGroup!.customers.items.map(customer => customer.id)).toContain(secondCustomer.id);
+        });
+
+        it('throws when deleting customer from other channel from customerGroup', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<RemoveCustomersFromGroup.Mutation, RemoveCustomersFromGroup.Variables>(
+                REMOVE_CUSTOMERS_FROM_GROUP,
+                {
+                    groupId: customerGroupId,
+                    customerIds: [thirdCustomer.id],
+                },
+            );
+        });
+    });
+});
+
+export const CREATE_ADDRESS = gql`
+    mutation CreateAddress($id: ID!, $input: CreateAddressInput!) {
+        createCustomerAddress(customerId: $id, input: $input) {
+            id
+            fullName
+            company
+            streetLine1
+            streetLine2
+            city
+            province
+            postalCode
+            country {
+                code
+                name
+            }
+            phoneNumber
+            defaultShippingAddress
+            defaultBillingAddress
+        }
+    }
+`;
+
+export const UPDATE_ADDRESS = gql`
+    mutation UpdateAddress($input: UpdateAddressInput!) {
+        updateCustomerAddress(input: $input) {
+            id
+            defaultShippingAddress
+            defaultBillingAddress
+            country {
+                code
+                name
+            }
+        }
+    }
+`;
+
+export const CREATE_CUSTOMER = gql`
+    mutation CreateCustomer($input: CreateCustomerInput!, $password: String) {
+        createCustomer(input: $input, password: $password) {
+            ...Customer
+        }
+    }
+    ${CUSTOMER_FRAGMENT}
+`;
+
+export const UPDATE_CUSTOMER = gql`
+    mutation UpdateCustomer($input: UpdateCustomerInput!) {
+        updateCustomer(input: $input) {
+            ...Customer
+        }
+    }
+    ${CUSTOMER_FRAGMENT}
+`;
+
+export const DELETE_CUSTOMER = gql`
+    mutation DeleteCustomer($id: ID!) {
+        deleteCustomer(id: $id) {
+            result
+        }
+    }
+`;
+
+export const UPDATE_CUSTOMER_NOTE = gql`
+    mutation UpdateCustomerNote($input: UpdateCustomerNoteInput!) {
+        updateCustomerNote(input: $input) {
+            id
+            data
+            isPublic
+        }
+    }
+`;
+
+export const DELETE_CUSTOMER_NOTE = gql`
+    mutation DeleteCustomerNote($id: ID!) {
+        deleteCustomerNote(id: $id) {
+            result
+            message
+        }
+    }
+`;
+
+export const UPDATE_CUSTOMER_GROUP = gql`
+    mutation UpdateCustomerGroup($input: UpdateCustomerGroupInput!) {
+        updateCustomerGroup(input: $input) {
+            ...CustomerGroup
+        }
+    }
+    ${CUSTOMER_GROUP_FRAGMENT}
+`;
+
+export const DELETE_CUSTOMER_GROUP = gql`
+    mutation DeleteCustomerGroup($id: ID!) {
+        deleteCustomerGroup(id: $id) {
+            result
+            message
+        }
+    }
+`;
+
+export const GET_CUSTOMER_GROUPS = gql`
+    query GetCustomerGroups($options: CustomerGroupListOptions) {
+        customerGroups(options: $options) {
+            items {
+                id
+                name
+            }
+            totalItems
+        }
+    }
+`;
+
+export const GET_CUSTOMER_GROUP = gql`
+    query GetCustomerGroup($id: ID!, $options: CustomerListOptions) {
+        customerGroup(id: $id) {
+            id
+            name
+            customers(options: $options) {
+                items {
+                    id
+                }
+                totalItems
+            }
+        }
+    }
+`;
+
+export const ADD_CUSTOMERS_TO_GROUP = gql`
+    mutation AddCustomersToGroup($groupId: ID!, $customerIds: [ID!]!) {
+        addCustomersToGroup(customerGroupId: $groupId, customerIds: $customerIds) {
+            ...CustomerGroup
+        }
+    }
+    ${CUSTOMER_GROUP_FRAGMENT}
+`;
+
+export const GET_CUSTOMER_WITH_GROUPS = gql`
+    query GetCustomerWithGroups($id: ID!) {
+        customer(id: $id) {
+            id
+            groups {
+                id
+                name
+            }
+        }
+    }
+`;

+ 10 - 10
packages/core/e2e/customer.e2e-spec.ts

@@ -52,7 +52,7 @@ let sendEmailFn: jest.Mock;
 class TestEmailPlugin implements OnModuleInit {
     constructor(private eventBus: EventBus) {}
     onModuleInit() {
-        this.eventBus.ofType(AccountRegistrationEvent).subscribe((event) => {
+        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
             sendEmailFn(event);
         });
     }
@@ -170,7 +170,7 @@ describe('Customer resolver', () => {
             });
 
             expect(result.customer!.addresses!.length).toBe(2);
-            firstCustomerAddressIds = result.customer!.addresses!.map((a) => a.id).sort();
+            firstCustomerAddressIds = result.customer!.addresses!.map(a => a.id).sort();
         });
 
         it('updateCustomerAddress updates the country', async () => {
@@ -209,7 +209,7 @@ describe('Customer resolver', () => {
                 id: firstCustomer.id,
             });
             const otherAddress = result2.customer!.addresses!.filter(
-                (a) => a.id !== firstCustomerAddressIds[1],
+                a => a.id !== firstCustomerAddressIds[1],
             )[0]!;
             expect(otherAddress.defaultShippingAddress).toBe(false);
             expect(otherAddress.defaultBillingAddress).toBe(false);
@@ -233,7 +233,7 @@ describe('Customer resolver', () => {
                 id: firstCustomer.id,
             });
             const otherAddress2 = result4.customer!.addresses!.filter(
-                (a) => a.id !== firstCustomerAddressIds[0],
+                a => a.id !== firstCustomerAddressIds[0],
             )[0]!;
             expect(otherAddress2.defaultShippingAddress).toBe(false);
             expect(otherAddress2.defaultBillingAddress).toBe(false);
@@ -336,10 +336,10 @@ describe('Customer resolver', () => {
             );
             expect(customer!.addresses!.length).toBe(2);
             const defaultAddress = customer!.addresses!.filter(
-                (a) => a.defaultBillingAddress && a.defaultShippingAddress,
+                a => a.defaultBillingAddress && a.defaultShippingAddress,
             );
             const otherAddress = customer!.addresses!.filter(
-                (a) => !a.defaultBillingAddress && !a.defaultShippingAddress,
+                a => !a.defaultBillingAddress && !a.defaultShippingAddress,
             );
             expect(defaultAddress.length).toBe(1);
             expect(otherAddress.length).toBe(1);
@@ -448,7 +448,7 @@ describe('Customer resolver', () => {
                 GET_CUSTOMER_LIST,
             );
 
-            expect(result.customers.items.map((c) => c.id).includes(thirdCustomer.id)).toBe(false);
+            expect(result.customers.items.map(c => c.id).includes(thirdCustomer.id)).toBe(false);
         });
 
         it(
@@ -593,7 +593,7 @@ const GET_CUSTOMER_WITH_USER = gql`
     }
 `;
 
-const CREATE_ADDRESS = gql`
+export const CREATE_ADDRESS = gql`
     mutation CreateAddress($id: ID!, $input: CreateAddressInput!) {
         createCustomerAddress(customerId: $id, input: $input) {
             id
@@ -615,7 +615,7 @@ const CREATE_ADDRESS = gql`
     }
 `;
 
-const UPDATE_ADDRESS = gql`
+export const UPDATE_ADDRESS = gql`
     mutation UpdateAddress($input: UpdateAddressInput!) {
         updateCustomerAddress(input: $input) {
             id
@@ -660,7 +660,7 @@ export const UPDATE_CUSTOMER = gql`
     ${CUSTOMER_FRAGMENT}
 `;
 
-const DELETE_CUSTOMER = gql`
+export const DELETE_CUSTOMER = gql`
     mutation DeleteCustomer($id: ID!) {
         deleteCustomer(id: $id) {
             result

+ 44 - 1
packages/core/src/api/middleware/auth-guard.ts

@@ -6,9 +6,13 @@ import { Request, Response } from 'express';
 import { ForbiddenError } from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
 import { CachedSession } from '../../config/session-cache/session-cache-strategy';
+import { Customer } from '../../entity/customer/customer.entity';
+import { ChannelService } from '../../service/services/channel.service';
+import { CustomerService } from '../../service/services/customer.service';
 import { SessionService } from '../../service/services/session.service';
 import { extractSessionToken } from '../common/extract-session-token';
 import { parseContext } from '../common/parse-context';
+import { RequestContext } from '../common/request-context';
 import { REQUEST_CONTEXT_KEY, RequestContextService } from '../common/request-context.service';
 import { setSessionToken } from '../common/set-session-token';
 import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';
@@ -26,6 +30,8 @@ export class AuthGuard implements CanActivate {
         private configService: ConfigService,
         private requestContextService: RequestContextService,
         private sessionService: SessionService,
+        private customerService: CustomerService,
+        private channelService: ChannelService,
     ) {}
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -35,7 +41,12 @@ export class AuthGuard implements CanActivate {
         const isPublic = !!permissions && permissions.includes(Permission.Public);
         const hasOwnerPermission = !!permissions && permissions.includes(Permission.Owner);
         const session = await this.getSession(req, res, hasOwnerPermission);
-        const requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);
+        let requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);
+
+        const requestContextShouldBeReinitialized = await this.setActiveChannel(requestContext, session);
+        if (requestContextShouldBeReinitialized) {
+            requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);
+        }
         (req as any)[REQUEST_CONTEXT_KEY] = requestContext;
 
         if (authDisabled || !permissions || isPublic) {
@@ -50,6 +61,38 @@ export class AuthGuard implements CanActivate {
         }
     }
 
+    private async setActiveChannel(
+        requestContext: RequestContext,
+        session?: CachedSession,
+    ): Promise<boolean> {
+        if (!session) {
+            return false;
+        }
+        // In case the session does not have an activeChannelId or the activeChannelId
+        // does not correspond to the current channel, the activeChannelId on the session is set
+        const activeChannelShouldBeSet =
+            !session.activeChannelId || session.activeChannelId !== requestContext.channelId;
+        if (activeChannelShouldBeSet) {
+            await this.sessionService.setActiveChannel(session, requestContext.channel);
+            if (requestContext.activeUserId) {
+                const customer = await this.customerService.findOneByUserId(
+                    requestContext,
+                    requestContext.activeUserId,
+                    false,
+                );
+                // To avoid assigning the customer to the active channel on every request,
+                // it is only done on the first request and whenever the channel changes
+                if (customer) {
+                    await this.channelService.assignToChannels(Customer, customer.id, [
+                        requestContext.channelId,
+                    ]);
+                }
+            }
+            return true;
+        }
+        return false;
+    }
+
     private async getSession(
         req: Request,
         res: Response,

+ 15 - 6
packages/core/src/api/resolvers/admin/customer.resolver.ts

@@ -31,14 +31,20 @@ export class CustomerResolver {
 
     @Query()
     @Allow(Permission.ReadCustomer)
-    async customers(@Args() args: QueryCustomersArgs): Promise<PaginatedList<Customer>> {
-        return this.customerService.findAll(args.options || undefined);
+    async customers(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryCustomersArgs,
+    ): Promise<PaginatedList<Customer>> {
+        return this.customerService.findAll(ctx, args.options || undefined);
     }
 
     @Query()
     @Allow(Permission.ReadCustomer)
-    async customer(@Args() args: QueryCustomerArgs): Promise<Customer | undefined> {
-        return this.customerService.findOne(args.id);
+    async customer(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryCustomerArgs,
+    ): Promise<Customer | undefined> {
+        return this.customerService.findOne(ctx, args.id);
     }
 
     @Mutation()
@@ -93,8 +99,11 @@ export class CustomerResolver {
 
     @Mutation()
     @Allow(Permission.DeleteCustomer)
-    async deleteCustomer(@Args() args: MutationDeleteCustomerArgs): Promise<DeletionResponse> {
-        return this.customerService.softDelete(args.id);
+    async deleteCustomer(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationDeleteCustomerArgs,
+    ): Promise<DeletionResponse> {
+        return this.customerService.softDelete(ctx, args.id);
     }
 
     @Mutation()

+ 1 - 1
packages/core/src/api/resolvers/entity/customer-entity.resolver.ts

@@ -67,7 +67,7 @@ export class CustomerAdminEntityResolver {
         if (customer.groups) {
             return customer.groups;
         }
-        return this.customerService.getCustomerGroups(customer.id);
+        return this.customerService.getCustomerGroups(ctx, customer.id);
     }
 
     @ResolveField()

+ 1 - 1
packages/core/src/api/resolvers/entity/customer-group-entity.resolver.ts

@@ -18,6 +18,6 @@ export class CustomerGroupEntityResolver {
         @Parent() customerGroup: CustomerGroup,
         @Args() args: QueryCustomersArgs,
     ): Promise<PaginatedList<Customer>> {
-        return this.customerGroupService.getGroupCustomers(customerGroup.id, args.options || undefined);
+        return this.customerGroupService.getGroupCustomers(ctx, customerGroup.id, args.options || undefined);
     }
 }

+ 3 - 3
packages/core/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -50,7 +50,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
     ) {
         super(authService, userService, administratorService, configService);
         this.nativeAuthStrategyIsConfigured = !!this.configService.authOptions.shopAuthenticationStrategy.find(
-            (strategy) => strategy.name === NATIVE_AUTH_STRATEGY_NAME,
+            strategy => strategy.name === NATIVE_AUTH_STRATEGY_NAME,
         );
     }
 
@@ -194,7 +194,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         this.requireNativeAuthStrategy();
         const result = await super.updatePassword(ctx, args.currentPassword, args.newPassword);
         if (result && ctx.activeUserId) {
-            const customer = await this.customerService.findOneByUserId(ctx.activeUserId);
+            const customer = await this.customerService.findOneByUserId(ctx, ctx.activeUserId);
             if (customer) {
                 await this.historyService.createHistoryEntryForCustomer({
                     ctx,
@@ -234,7 +234,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
     private requireNativeAuthStrategy() {
         if (!this.nativeAuthStrategyIsConfigured) {
             const authStrategyNames = this.configService.authOptions.shopAuthenticationStrategy
-                .map((s) => s.name)
+                .map(s => s.name)
                 .join(', ');
             const errorMessage =
                 'This GraphQL operation requires that the NativeAuthenticationStrategy be configured for the Shop API.\n' +

+ 2 - 2
packages/core/src/api/resolvers/shop/shop-customer.resolver.ts

@@ -24,7 +24,7 @@ export class ShopCustomerResolver {
     async activeCustomer(@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
         const userId = ctx.activeUserId;
         if (userId) {
-            return this.customerService.findOneByUserId(userId);
+            return this.customerService.findOneByUserId(ctx, userId);
         }
     }
 
@@ -87,7 +87,7 @@ export class ShopCustomerResolver {
         if (!userId) {
             throw new ForbiddenError();
         }
-        const customer = await this.customerService.findOneByUserId(userId);
+        const customer = await this.customerService.findOneByUserId(ctx, userId);
         if (!customer) {
             throw new InternalServerError(`error.no-customer-found-for-current-user`);
         }

+ 1 - 1
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -333,7 +333,7 @@ export class ShopOrderResolver {
             }
             const sessionOrder = await this.getOrderFromContext(ctx);
             if (sessionOrder) {
-                const customer = await this.customerService.createOrUpdate(args.input, true);
+                const customer = await this.customerService.createOrUpdate(ctx, args.input, true);
                 return this.orderService.addCustomerToOrder(ctx, sessionOrder.id, customer);
             }
         }

+ 2 - 1
packages/core/src/config/promotion/conditions/contains-products-condition.ts

@@ -1,6 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { idsAreEqual } from '../../../common/utils';
 import { OrderLine } from '../../../entity/order-line/order-line.entity';
 import { Order } from '../../../entity/order/order.entity';
@@ -20,7 +21,7 @@ export const containsProducts = new PromotionCondition({
             label: [{ languageCode: LanguageCode.en, value: 'Product variants' }],
         },
     },
-    async check(order: Order, args) {
+    async check(ctx: RequestContext, order: Order, args) {
         const ids = args.productVariantIds;
         let matches = 0;
         for (const line of order.lines) {

+ 4 - 3
packages/core/src/config/promotion/conditions/customer-group-condition.ts

@@ -1,6 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { TtlCache } from '../../../common/ttl-cache';
 import { idsAreEqual } from '../../../common/utils';
 import { Order } from '../../../entity/order/order.entity';
@@ -26,15 +27,15 @@ export const customerGroup = new PromotionCondition({
         const { CustomerService } = await import('../../../service/services/customer.service');
         customerService = injector.get(CustomerService);
     },
-    async check(order: Order, args) {
+    async check(ctx: RequestContext, order: Order, args) {
         if (!order.customer) {
             return false;
         }
         const customerId = order.customer.id;
         let groupIds = cache.get(customerId);
         if (!groupIds) {
-            const groups = await customerService.getCustomerGroups(customerId);
-            groupIds = groups.map((g) => g.id);
+            const groups = await customerService.getCustomerGroups(ctx, customerId);
+            groupIds = groups.map(g => g.id);
             cache.set(customerId, groupIds);
         }
         return !!groupIds.find((id) => idsAreEqual(id, args.customerGroupId));

+ 2 - 1
packages/core/src/config/promotion/conditions/has-facet-values-condition.ts

@@ -1,5 +1,6 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
 import { FacetValueChecker } from '../utils/facet-value-checker';
@@ -19,7 +20,7 @@ export const hasFacetValues = new PromotionCondition({
         facetValueChecker = new FacetValueChecker(injector.getConnection());
     },
     // tslint:disable-next-line:no-shadowed-variable
-    async check(order: Order, args) {
+    async check(ctx: RequestContext, order: Order, args) {
         let matches = 0;
         for (const line of order.lines) {
             if (await facetValueChecker.hasFacetValues(line, args.facets)) {

+ 3 - 1
packages/core/src/config/promotion/conditions/min-order-amount-condition.ts

@@ -1,5 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
+import { RequestContext } from '../../../api/common/request-context';
+import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
 
 export const minimumOrderAmount = new PromotionCondition({
@@ -12,7 +14,7 @@ export const minimumOrderAmount = new PromotionCondition({
         },
         taxInclusive: { type: 'boolean' },
     },
-    check(order, args) {
+    check(ctx: RequestContext, order: Order, args) {
         if (args.taxInclusive) {
             return order.subTotal >= args.amount;
         } else {

+ 4 - 2
packages/core/src/config/promotion/promotion-condition.ts

@@ -1,6 +1,7 @@
 import { ConfigArg } from '@vendure/common/lib/generated-types';
 import { ConfigArgType, ID } from '@vendure/common/lib/shared-types';
 
+import { RequestContext } from '../../api/common/request-context';
 import {
     ConfigArgs,
     ConfigArgValues,
@@ -18,6 +19,7 @@ import { Order } from '../../entity/order/order.entity';
  * @docsPage promotion-condition
  */
 export type CheckPromotionConditionFn<T extends ConfigArgs> = (
+    ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
 ) => boolean | Promise<boolean>;
@@ -61,7 +63,7 @@ export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends Confi
         this.priorityValue = config.priorityValue || 0;
     }
 
-    async check(order: Order, args: ConfigArg[]): Promise<boolean> {
-        return this.checkFn(order, this.argsArrayToHash(args));
+    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<boolean> {
+        return this.checkFn(ctx, order, this.argsArrayToHash(args));
     }
 }

+ 1 - 0
packages/core/src/config/session-cache/session-cache-strategy.ts

@@ -40,6 +40,7 @@ export type CachedSession = {
     activeOrderId?: ID;
     authenticationStrategy?: string;
     user?: CachedSessionUser;
+    activeChannelId?: ID;
 };
 
 /**

+ 12 - 7
packages/core/src/entity/customer/customer.entity.ts

@@ -1,10 +1,11 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne } from 'typeorm';
 
-import { SoftDeletable } from '../../common/types/common-types';
+import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { Address } from '../address/address.entity';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { CustomCustomerFields } from '../custom-entity-fields';
 import { CustomerGroup } from '../customer-group/customer-group.entity';
 import { Order } from '../order/order.entity';
@@ -19,7 +20,7 @@ import { User } from '../user/user.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Customer extends VendureEntity implements HasCustomFields, SoftDeletable {
+export class Customer extends VendureEntity implements ChannelAware, HasCustomFields, SoftDeletable {
     constructor(input?: DeepPartial<Customer>) {
         super(input);
     }
@@ -40,20 +41,24 @@ export class Customer extends VendureEntity implements HasCustomFields, SoftDele
     @Column()
     emailAddress: string;
 
-    @ManyToMany((type) => CustomerGroup, (group) => group.customers)
+    @ManyToMany(type => CustomerGroup, group => group.customers)
     @JoinTable()
     groups: CustomerGroup[];
 
-    @OneToMany((type) => Address, (address) => address.customer)
+    @OneToMany(type => Address, address => address.customer)
     addresses: Address[];
 
-    @OneToMany((type) => Order, (order) => order.customer)
+    @OneToMany(type => Order, order => order.customer)
     orders: Order[];
 
-    @OneToOne((type) => User, { eager: true })
+    @OneToOne(type => User, { eager: true })
     @JoinColumn()
     user?: User;
 
-    @Column((type) => CustomCustomerFields)
+    @Column(type => CustomCustomerFields)
     customFields: CustomCustomerFields;
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 3 - 2
packages/core/src/entity/promotion/promotion.entity.ts

@@ -2,6 +2,7 @@ import { Adjustment, AdjustmentType, ConfigurableOperation } from '@vendure/comm
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
+import { RequestContext } from '../../api/common/request-context';
 import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { getConfig } from '../../config/config-helpers';
@@ -127,7 +128,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
     }
 
-    async test(order: Order): Promise<boolean> {
+    async test(ctx: RequestContext, order: Order): Promise<boolean> {
         if (this.endsAt && this.endsAt < new Date()) {
             return false;
         }
@@ -139,7 +140,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
         for (const condition of this.conditions) {
             const promotionCondition = this.allConditions[condition.code];
-            if (!promotionCondition || !(await promotionCondition.check(order, condition.args))) {
+            if (!promotionCondition || !(await promotionCondition.check(ctx, order, condition.args))) {
                 return false;
             }
         }

+ 8 - 1
packages/core/src/entity/session/session.entity.ts

@@ -2,6 +2,7 @@ import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, ManyToOne, TableInheritance } from 'typeorm';
 
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { Customer } from '../customer/customer.entity';
 import { EntityId } from '../entity-id.decorator';
 import { Order } from '../order/order.entity';
@@ -28,6 +29,12 @@ export abstract class Session extends VendureEntity {
     @EntityId({ nullable: true })
     activeOrderId?: ID;
 
-    @ManyToOne((type) => Order)
+    @ManyToOne(type => Order)
     activeOrder: Order | null;
+
+    @EntityId({ nullable: true })
+    activeChannelId?: ID;
+
+    @ManyToOne(type => Channel)
+    activeChannel: Channel | null;
 }

+ 16 - 10
packages/core/src/service/helpers/external-authentication/external-authentication.service.ts

@@ -10,6 +10,7 @@ import { Customer } from '../../../entity/customer/customer.entity';
 import { Role } from '../../../entity/role/role.entity';
 import { User } from '../../../entity/user/user.entity';
 import { AdministratorService } from '../../services/administrator.service';
+import { ChannelService } from '../../services/channel.service';
 import { CustomerService } from '../../services/customer.service';
 import { HistoryService } from '../../services/history.service';
 import { RoleService } from '../../services/role.service';
@@ -29,6 +30,7 @@ export class ExternalAuthenticationService {
         private historyService: HistoryService,
         private customerService: CustomerService,
         private administratorService: AdministratorService,
+        private channelService: ChannelService,
     ) {}
 
     /**
@@ -36,12 +38,16 @@ export class ExternalAuthenticationService {
      * Looks up a User based on their identifier from an external authentication
      * provider, ensuring this User is associated with a Customer account.
      */
-    async findCustomerUser(strategy: string, externalIdentifier: string): Promise<User | undefined> {
+    async findCustomerUser(
+        ctx: RequestContext,
+        strategy: string,
+        externalIdentifier: string,
+    ): Promise<User | undefined> {
         const user = await this.findUser(strategy, externalIdentifier);
 
         if (user) {
             // Ensure this User is associated with a Customer
-            const customer = await this.customerService.findOneByUserId(user.id);
+            const customer = await this.customerService.findOneByUserId(ctx, user.id);
             if (customer) {
                 return user;
             }
@@ -99,14 +105,14 @@ export class ExternalAuthenticationService {
         newUser.authenticationMethods = [authMethod];
         const savedUser = await this.connection.manager.save(newUser);
 
-        const customer = await this.connection.manager.save(
-            new Customer({
-                emailAddress: config.emailAddress,
-                firstName: config.firstName,
-                lastName: config.lastName,
-                user: savedUser,
-            }),
-        );
+        const customer = new Customer({
+            emailAddress: config.emailAddress,
+            firstName: config.firstName,
+            lastName: config.lastName,
+            user: savedUser,
+        });
+        this.channelService.assignToCurrentChannel(customer, ctx);
+        await this.connection.manager.save(customer);
 
         await this.historyService.createHistoryEntryForCustomer({
             customerId: customer.id,

+ 2 - 2
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -145,7 +145,7 @@ describe('OrderCalculator', () => {
             args: { minimum: { type: 'int' } },
             code: 'order_total_condition',
             description: [{ languageCode: LanguageCode.en, value: '' }],
-            check(order, args) {
+            check(ctx, order, args) {
                 return args.minimum <= order.total;
             },
         });
@@ -358,7 +358,7 @@ describe('OrderCalculator', () => {
                         value: 'Passes if any order line has at least the minimum quantity',
                     },
                 ],
-                check(_order, args) {
+                check(ctx, _order, args) {
                     for (const line of _order.lines) {
                         if (args.minimum <= line.quantity) {
                             return true;

+ 14 - 10
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -72,7 +72,7 @@ export class OrderCalculator {
 
             // Then test and apply promotions
             const totalBeforePromotions = order.total;
-            const itemsModifiedByPromotions = await this.applyPromotions(order, promotions);
+            const itemsModifiedByPromotions = await this.applyPromotions(ctx, order, promotions);
             itemsModifiedByPromotions.forEach(item => updatedOrderItems.add(item));
 
             if (order.total !== totalBeforePromotions || itemsModifiedByPromotions.length) {
@@ -149,9 +149,13 @@ export class OrderCalculator {
      * Applies any eligible promotions to each OrderItem in the order. Returns an array of
      * any OrderItems which had their Adjustments modified.
      */
-    private async applyPromotions(order: Order, promotions: Promotion[]): Promise<OrderItem[]> {
-        const updatedItems = await this.applyOrderItemPromotions(order, promotions);
-        await this.applyOrderPromotions(order, promotions);
+    private async applyPromotions(
+        ctx: RequestContext,
+        order: Order,
+        promotions: Promotion[],
+    ): Promise<OrderItem[]> {
+        const updatedItems = await this.applyOrderItemPromotions(ctx, order, promotions);
+        await this.applyOrderPromotions(ctx, order, promotions);
         return updatedItems;
     }
 
@@ -160,7 +164,7 @@ export class OrderCalculator {
      * of applying the promotions, and also due to added complexity in the name of performance
      * optimization. Therefore it is heavily annotated so that the purpose of each step is clear.
      */
-    private async applyOrderItemPromotions(order: Order, promotions: Promotion[]) {
+    private async applyOrderItemPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
         // The naive implementation updates *every* OrderItem after this function is run.
         // However, on a very large order with hundreds or thousands of OrderItems, this results in
         // very poor performance. E.g. updating a single quantity of an OrderLine results in saving
@@ -172,7 +176,7 @@ export class OrderCalculator {
         for (const line of order.lines) {
             // Must be re-calculated for each line, since the previous lines may have triggered promotions
             // which affected the order price.
-            const applicablePromotions = await filterAsync(promotions, p => p.test(order));
+            const applicablePromotions = await filterAsync(promotions, p => p.test(ctx, order));
 
             const lineHasExistingPromotions =
                 line.items[0].pendingAdjustments &&
@@ -194,7 +198,7 @@ export class OrderCalculator {
                 // We need to test the promotion *again*, even though we've tested them for the line.
                 // This is because the previous Promotions may have adjusted the Order in such a way
                 // as to render later promotions no longer applicable.
-                if (await promotion.test(order)) {
+                if (await promotion.test(ctx, order)) {
                     for (const item of line.items) {
                         const adjustment = await promotion.apply({
                             orderItem: item,
@@ -251,14 +255,14 @@ export class OrderCalculator {
         return hasPromotionsThatAreNoLongerApplicable;
     }
 
-    private async applyOrderPromotions(order: Order, promotions: Promotion[]) {
+    private async applyOrderPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
         order.clearAdjustments(AdjustmentType.PROMOTION);
-        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(order));
+        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order));
         if (applicableOrderPromotions.length) {
             for (const promotion of applicableOrderPromotions) {
                 // re-test the promotion on each iteration, since the order total
                 // may be modified by a previously-applied promotion
-                if (await promotion.test(order)) {
+                if (await promotion.test(ctx, order)) {
                     const adjustment = await promotion.apply({ order });
                     if (adjustment) {
                         order.pendingAdjustments = order.pendingAdjustments.concat(adjustment);

+ 24 - 9
packages/core/src/service/services/customer-group.service.ts

@@ -44,11 +44,17 @@ export class CustomerGroupService {
         return this.connection.getRepository(CustomerGroup).findOne(customerGroupId);
     }
 
-    getGroupCustomers(customerGroupId: ID, options?: CustomerListOptions): Promise<PaginatedList<Customer>> {
+    getGroupCustomers(
+        ctx: RequestContext,
+        customerGroupId: ID,
+        options?: CustomerListOptions,
+    ): Promise<PaginatedList<Customer>> {
         return this.listQueryBuilder
             .build(Customer, options)
             .leftJoin('customer.groups', 'group')
+            .leftJoin('customer.channels', 'channel')
             .andWhere('group.id = :groupId', { groupId: customerGroupId })
+            .andWhere('channel.id =:channelId', { channelId: ctx.channelId })
             .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
@@ -58,7 +64,7 @@ export class CustomerGroupService {
 
         const newCustomerGroup = await this.connection.getRepository(CustomerGroup).save(customerGroup);
         if (input.customerIds) {
-            const customers = await this.getCustomersFromIds(input.customerIds);
+            const customers = await this.getCustomersFromIds(ctx, input.customerIds);
             for (const customer of customers) {
                 customer.groups = [...(customer.groups || []), newCustomerGroup];
                 await this.historyService.createHistoryEntryForCustomer({
@@ -101,10 +107,10 @@ export class CustomerGroupService {
         ctx: RequestContext,
         input: MutationAddCustomersToGroupArgs,
     ): Promise<CustomerGroup> {
-        const customers = await this.getCustomersFromIds(input.customerIds);
+        const customers = await this.getCustomersFromIds(ctx, input.customerIds);
         const group = await getEntityOrThrow(this.connection, CustomerGroup, input.customerGroupId);
         for (const customer of customers) {
-            if (!customer.groups.map((g) => g.id).includes(input.customerGroupId)) {
+            if (!customer.groups.map(g => g.id).includes(input.customerGroupId)) {
                 customer.groups.push(group);
                 await this.historyService.createHistoryEntryForCustomer({
                     ctx,
@@ -125,13 +131,13 @@ export class CustomerGroupService {
         ctx: RequestContext,
         input: MutationRemoveCustomersFromGroupArgs,
     ): Promise<CustomerGroup> {
-        const customers = await this.getCustomersFromIds(input.customerIds);
+        const customers = await this.getCustomersFromIds(ctx, input.customerIds);
         const group = await getEntityOrThrow(this.connection, CustomerGroup, input.customerGroupId);
         for (const customer of customers) {
-            if (!customer.groups.map((g) => g.id).includes(input.customerGroupId)) {
+            if (!customer.groups.map(g => g.id).includes(input.customerGroupId)) {
                 throw new UserInputError('error.customer-does-not-belong-to-customer-group');
             }
-            customer.groups = customer.groups.filter((g) => !idsAreEqual(g.id, group.id));
+            customer.groups = customer.groups.filter(g => !idsAreEqual(g.id, group.id));
             await this.historyService.createHistoryEntryForCustomer({
                 ctx,
                 customerId: customer.id,
@@ -145,9 +151,18 @@ export class CustomerGroupService {
         return assertFound(this.findOne(group.id));
     }
 
-    private getCustomersFromIds(ids: ID[]): Promise<Customer[]> {
+    private getCustomersFromIds(ctx: RequestContext, ids: ID[]): Promise<Customer[]> | Customer[] {
+        if (ids.length === 0) {
+            return new Array<Customer>();
+        } // TypeORM throws error when list is empty
         return this.connection
             .getRepository(Customer)
-            .findByIds(ids, { where: { deletedAt: null }, relations: ['groups'] });
+            .createQueryBuilder('customer')
+            .leftJoin('customer.channels', 'channel')
+            .leftJoinAndSelect('customer.groups', 'group')
+            .where('customer.id IN (:...customerIds)', { customerIds: ids })
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+            .andWhere('customer.deletedAt is null')
+            .getMany();
     }
 }

+ 103 - 33
packages/core/src/service/services/customer.service.ts

@@ -28,6 +28,7 @@ import { NATIVE_AUTH_STRATEGY_NAME } from '../../config/auth/native-authenticati
 import { ConfigService } from '../../config/config.service';
 import { Address } from '../../entity/address/address.entity';
 import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
+import { Channel } from '../../entity/channel/channel.entity';
 import { CustomerGroup } from '../../entity/customer-group/customer-group.entity';
 import { Customer } from '../../entity/customer/customer.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
@@ -39,10 +40,12 @@ import { IdentifierChangeRequestEvent } from '../../event-bus/events/identifier-
 import { PasswordResetEvent } from '../../event-bus/events/password-reset-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { addressToLine } from '../helpers/utils/address-to-line';
+import { findOneInChannel } from '../helpers/utils/channel-aware-orm-utils';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
+import { ChannelService } from './channel.service';
 import { CountryService } from './country.service';
 import { HistoryService } from './history.service';
 import { UserService } from './user.service';
@@ -57,29 +60,47 @@ export class CustomerService {
         private listQueryBuilder: ListQueryBuilder,
         private eventBus: EventBus,
         private historyService: HistoryService,
+        private channelService: ChannelService,
     ) {}
 
-    findAll(options: ListQueryOptions<Customer> | undefined): Promise<PaginatedList<Customer>> {
+    findAll(
+        ctx: RequestContext,
+        options: ListQueryOptions<Customer> | undefined,
+    ): Promise<PaginatedList<Customer>> {
         return this.listQueryBuilder
-            .build(Customer, options, { where: { deletedAt: null } })
+            .build(Customer, options, {
+                relations: ['channels'],
+                channelId: ctx.channelId,
+                where: { deletedAt: null },
+            })
             .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
 
-    findOne(id: ID): Promise<Customer | undefined> {
-        return this.connection.getRepository(Customer).findOne(id, { where: { deletedAt: null } });
-    }
-
-    findOneByUserId(userId: ID): Promise<Customer | undefined> {
-        return this.connection.getRepository(Customer).findOne({
+    findOne(ctx: RequestContext, id: ID): Promise<Customer | undefined> {
+        return findOneInChannel(this.connection, Customer, id, ctx.channelId, {
             where: {
-                user: { id: userId },
                 deletedAt: null,
             },
         });
     }
 
-    findAddressesByCustomerId(ctx: RequestContext, customerId: ID): Promise<Address[]> {
+    findOneByUserId(ctx: RequestContext, userId: ID, filterOnChannel = true): Promise<Customer | undefined> {
+        let query = this.connection
+            .getRepository(Customer)
+            .createQueryBuilder('customer')
+            .leftJoin('customer.channels', 'channel')
+            .leftJoinAndSelect('customer.user', 'user')
+            .where('user.id = :userId', { userId })
+            .andWhere('customer.deletedAt is null');
+        if (filterOnChannel) {
+            query = query.andWhere('channel.id = :channelId', { channelId: ctx.channelId });
+        }
+        return query.getOne();
+    }
+
+    async findAddressesByCustomerId(ctx: RequestContext, customerId: ID): Promise<Address[]> {
+        await getEntityOrThrow(this.connection, Customer, customerId, ctx.channelId);
         return this.connection
             .getRepository(Address)
             .createQueryBuilder('address')
@@ -95,10 +116,19 @@ export class CustomerService {
             });
     }
 
-    async getCustomerGroups(customerId: ID): Promise<CustomerGroup[]> {
-        const customerWithGroups = await this.connection
-            .getRepository(Customer)
-            .findOne(customerId, { relations: ['groups'] });
+    async getCustomerGroups(ctx: RequestContext, customerId: ID): Promise<CustomerGroup[]> {
+        const customerWithGroups = await findOneInChannel(
+            this.connection,
+            Customer,
+            customerId,
+            ctx.channelId,
+            {
+                relations: ['groups'],
+                where: {
+                    deletedAt: null,
+                },
+            },
+        );
         if (customerWithGroups) {
             return customerWithGroups.groups;
         } else {
@@ -110,7 +140,21 @@ export class CustomerService {
         input.emailAddress = normalizeEmailAddress(input.emailAddress);
         const customer = new Customer(input);
 
+        const existingCustomerInChannel = await this.connection
+            .getRepository(Customer)
+            .createQueryBuilder('customer')
+            .leftJoin('customer.channels', 'channel')
+            .where('channel.id = :channelId', { channelId: ctx.channelId })
+            .andWhere('customer.emailAddress = :emailAddress', { emailAddress: input.emailAddress })
+            .andWhere('customer.deletedAt is null')
+            .getOne();
+
+        if (existingCustomerInChannel) {
+            throw new UserInputError(`error.email-address-must-be-unique`);
+        }
+
         const existingCustomer = await this.connection.getRepository(Customer).findOne({
+            relations: ['channels'],
             where: {
                 emailAddress: input.emailAddress,
                 deletedAt: null,
@@ -123,7 +167,13 @@ export class CustomerService {
             },
         });
 
-        if (existingCustomer || existingUser) {
+        if (existingCustomer && existingUser) {
+            // Customer already exists, bring to this Channel
+            const updatedCustomer = patchEntity(existingCustomer, input);
+            updatedCustomer.channels.push(ctx.channel);
+            return this.connection.getRepository(Customer).save(updatedCustomer);
+        } else if (existingCustomer || existingUser) {
+            // Not sure when this situation would occur
             throw new UserInputError(`error.email-address-must-be-unique`);
         }
         customer.user = await this.userService.createCustomerUser(input.emailAddress, password);
@@ -136,7 +186,8 @@ export class CustomerService {
         } else {
             this.eventBus.publish(new AccountRegistrationEvent(ctx, customer.user));
         }
-        const createdCustomer = await await this.connection.getRepository(Customer).save(customer);
+        this.channelService.assignToCurrentChannel(customer, ctx);
+        const createdCustomer = await this.connection.getRepository(Customer).save(customer);
 
         await this.historyService.createHistoryEntryForCustomer({
             ctx,
@@ -178,7 +229,7 @@ export class CustomerService {
             }
         }
         const customFields = (input as any).customFields;
-        const customer = await this.createOrUpdate({
+        const customer = await this.createOrUpdate(ctx, {
             emailAddress: input.emailAddress,
             title: input.title || '',
             firstName: input.firstName || '',
@@ -241,7 +292,7 @@ export class CustomerService {
     ): Promise<Customer | undefined> {
         const user = await this.userService.verifyUserByToken(verificationToken, password);
         if (user) {
-            const customer = await this.findOneByUserId(user.id);
+            const customer = await this.findOneByUserId(ctx, user.id);
             if (!customer) {
                 throw new InternalServerError('error.cannot-locate-customer-for-user');
             }
@@ -253,7 +304,7 @@ export class CustomerService {
                     strategy: NATIVE_AUTH_STRATEGY_NAME,
                 },
             });
-            return this.findOneByUserId(user.id);
+            return this.findOneByUserId(ctx, user.id);
         }
     }
 
@@ -261,7 +312,7 @@ export class CustomerService {
         const user = await this.userService.setPasswordResetToken(emailAddress);
         if (user) {
             this.eventBus.publish(new PasswordResetEvent(ctx, user));
-            const customer = await this.findOneByUserId(user.id);
+            const customer = await this.findOneByUserId(ctx, user.id);
             if (!customer) {
                 throw new InternalServerError('error.cannot-locate-customer-for-user');
             }
@@ -281,7 +332,7 @@ export class CustomerService {
     ): Promise<Customer | undefined> {
         const user = await this.userService.resetPasswordByToken(passwordResetToken, password);
         if (user) {
-            const customer = await this.findOneByUserId(user.id);
+            const customer = await this.findOneByUserId(ctx, user.id);
             if (!customer) {
                 throw new InternalServerError('error.cannot-locate-customer-for-user');
             }
@@ -308,7 +359,7 @@ export class CustomerService {
         if (!user) {
             return false;
         }
-        const customer = await this.findOneByUserId(user.id);
+        const customer = await this.findOneByUserId(ctx, user.id);
         if (!customer) {
             return false;
         }
@@ -352,7 +403,7 @@ export class CustomerService {
         if (!user) {
             return false;
         }
-        const customer = await this.findOneByUserId(user.id);
+        const customer = await this.findOneByUserId(ctx, user.id);
         if (!customer) {
             return false;
         }
@@ -372,9 +423,9 @@ export class CustomerService {
     }
 
     async update(ctx: RequestContext, input: UpdateCustomerInput): Promise<Customer> {
-        const customer = await getEntityOrThrow(this.connection, Customer, input.id);
+        const customer = await getEntityOrThrow(this.connection, Customer, input.id, ctx.channelId);
         const updatedCustomer = patchEntity(customer, input);
-        await this.connection.getRepository(Customer).save(customer, { reload: false });
+        await this.connection.getRepository(Customer).save(updatedCustomer, { reload: false });
         await this.historyService.createHistoryEntryForCustomer({
             customerId: customer.id,
             ctx,
@@ -383,19 +434,21 @@ export class CustomerService {
                 input,
             },
         });
-        return assertFound(this.findOne(customer.id));
+        return assertFound(this.findOne(ctx, customer.id));
     }
 
     /**
      * For guest checkouts, we assume that a matching email address is the same customer.
      */
     async createOrUpdate(
+        ctx: RequestContext,
         input: Partial<CreateCustomerInput> & { emailAddress: string },
         throwOnExistingUser: boolean = false,
     ): Promise<Customer> {
         input.emailAddress = normalizeEmailAddress(input.emailAddress);
         let customer: Customer;
         const existing = await this.connection.getRepository(Customer).findOne({
+            relations: ['channels'],
             where: {
                 emailAddress: input.emailAddress,
                 deletedAt: null,
@@ -407,21 +460,20 @@ export class CustomerService {
                 throw new IllegalOperationError('error.cannot-use-registered-email-address-for-guest-order');
             }
             customer = patchEntity(existing, input);
+            customer.channels.push(await getEntityOrThrow(this.connection, Channel, ctx.channelId));
         } else {
             customer = new Customer(input);
+            this.channelService.assignToCurrentChannel(customer, ctx);
         }
         return this.connection.getRepository(Customer).save(customer);
     }
 
     async createAddress(ctx: RequestContext, customerId: ID, input: CreateAddressInput): Promise<Address> {
-        const customer = await this.connection.manager.findOne(Customer, customerId, {
+        const customer = await getEntityOrThrow(this.connection, Customer, customerId, ctx.channelId, {
             where: { deletedAt: null },
             relations: ['addresses'],
         });
 
-        if (!customer) {
-            throw new EntityNotFoundError('Customer', customerId);
-        }
         const country = await this.countryService.findOneByCode(ctx, input.countryCode);
         const address = new Address({
             ...input,
@@ -444,6 +496,15 @@ export class CustomerService {
         const address = await getEntityOrThrow(this.connection, Address, input.id, {
             relations: ['customer', 'country'],
         });
+        const customer = await findOneInChannel(
+            this.connection,
+            Customer,
+            address.customer.id,
+            ctx.channelId,
+        );
+        if (!customer) {
+            throw new EntityNotFoundError('Address', input.id);
+        }
         if (input.countryCode && input.countryCode !== address.country.code) {
             address.country = await this.countryService.findOneByCode(ctx, input.countryCode);
         } else {
@@ -469,6 +530,15 @@ export class CustomerService {
         const address = await getEntityOrThrow(this.connection, Address, id, {
             relations: ['customer', 'country'],
         });
+        const customer = await findOneInChannel(
+            this.connection,
+            Customer,
+            address.customer.id,
+            ctx.channelId,
+        );
+        if (!customer) {
+            throw new EntityNotFoundError('Address', id);
+        }
         address.country = translateDeep(address.country, ctx.languageCode);
         await this.reassignDefaultsForDeletedAddress(address);
         await this.historyService.createHistoryEntryForCustomer({
@@ -483,8 +553,8 @@ export class CustomerService {
         return true;
     }
 
-    async softDelete(customerId: ID): Promise<DeletionResponse> {
-        const customer = await getEntityOrThrow(this.connection, Customer, customerId);
+    async softDelete(ctx: RequestContext, customerId: ID): Promise<DeletionResponse> {
+        const customer = await getEntityOrThrow(this.connection, Customer, customerId, ctx.channelId);
         await this.connection.getRepository(Customer).update({ id: customerId }, { deletedAt: new Date() });
         // tslint:disable-next-line:no-non-null-assertion
         await this.userService.softDelete(customer.user!.id);
@@ -494,7 +564,7 @@ export class CustomerService {
     }
 
     async addNoteToCustomer(ctx: RequestContext, input: AddNoteToCustomerInput): Promise<Customer> {
-        const customer = await getEntityOrThrow(this.connection, Customer, input.id);
+        const customer = await getEntityOrThrow(this.connection, Customer, input.id, ctx.channelId);
         await this.historyService.createHistoryEntryForCustomer(
             {
                 ctx,

+ 3 - 3
packages/core/src/service/services/order.service.ts

@@ -220,7 +220,7 @@ export class OrderService {
     }
 
     async getActiveOrderForUser(ctx: RequestContext, userId: ID): Promise<Order | undefined> {
-        const customer = await this.customerService.findOneByUserId(userId);
+        const customer = await this.customerService.findOneByUserId(ctx, userId);
         if (customer) {
             const activeOrder = await this.connection
                 .createQueryBuilder(Order, 'order')
@@ -252,7 +252,7 @@ export class OrderService {
             currencyCode: ctx.channel.currencyCode,
         });
         if (userId) {
-            const customer = await this.customerService.findOneByUserId(userId);
+            const customer = await this.customerService.findOneByUserId(ctx, userId);
             if (customer) {
                 newOrder.customer = customer;
             }
@@ -827,7 +827,7 @@ export class OrderService {
                 order = await this.addItemToOrder(ctx, order.id, line.productVariantId, line.quantity);
             }
         }
-        const customer = await this.customerService.findOneByUserId(user.id);
+        const customer = await this.customerService.findOneByUserId(ctx, user.id);
         if (order && customer) {
             order.customer = customer;
             await this.connection.getRepository(Order).save(order, { reload: false });

+ 15 - 0
packages/core/src/service/services/session.service.ts

@@ -132,6 +132,7 @@ export class SessionService implements EntitySubscriberInterface {
             token: session.token,
             expires: session.expires,
             activeOrderId: session.activeOrderId,
+            activeChannelId: session.activeChannelId,
         };
         if (this.isAuthenticatedSession(session)) {
             serializedSession.authenticationStrategy = session.authenticationStrategy;
@@ -196,6 +197,20 @@ export class SessionService implements EntitySubscriberInterface {
         return serializedSession;
     }
 
+    async setActiveChannel(serializedSession: CachedSession, channel: Channel): Promise<CachedSession> {
+        const session = await this.connection
+            .getRepository(Session)
+            .findOne(serializedSession.id, { relations: ['user', 'user.roles', 'user.roles.channels'] });
+        if (session) {
+            session.activeChannel = channel;
+            await this.connection.getRepository(Session).save(session, { reload: false });
+            const updatedSerializedSession = this.serializeSession(session);
+            await this.sessionCacheStrategy.set(updatedSerializedSession);
+            return updatedSerializedSession;
+        }
+        return serializedSession;
+    }
+
     /**
      * Deletes all existing sessions for the given user.
      */