From 818da25a63e0557be425b71fb448da07032b10d5 Mon Sep 17 00:00:00 2001 From: Hendrik Depauw Date: Thu, 3 Sep 2020 22:48:49 +0200 Subject: [PATCH 1/4] feat(core): Make Customers ChannelAware --- .../core/e2e/customer-channel.e2e-spec.ts | 339 ++++++++++++++++++ packages/core/e2e/customer.e2e-spec.ts | 20 +- .../core/src/api/middleware/auth-guard.ts | 22 ++ .../api/resolvers/admin/customer.resolver.ts | 21 +- .../entity/customer-entity.resolver.ts | 2 +- .../entity/customer-group-entity.resolver.ts | 2 +- .../api/resolvers/shop/shop-auth.resolver.ts | 6 +- .../resolvers/shop/shop-customer.resolver.ts | 4 +- .../api/resolvers/shop/shop-order.resolver.ts | 2 +- .../conditions/contains-products-condition.ts | 3 +- .../conditions/customer-group-condition.ts | 5 +- .../conditions/has-facet-values-condition.ts | 3 +- .../conditions/min-order-amount-condition.ts | 4 +- .../config/promotion/promotion-condition.ts | 6 +- .../session-cache/session-cache-strategy.ts | 1 + .../src/entity/customer/customer.entity.ts | 19 +- .../src/entity/promotion/promotion.entity.ts | 5 +- .../core/src/entity/session/session.entity.ts | 9 +- .../external-authentication.service.ts | 8 +- .../order-calculator/order-calculator.ts | 24 +- .../services/customer-group.service.ts | 33 +- .../src/service/services/customer.service.ts | 136 +++++-- .../src/service/services/order.service.ts | 6 +- .../src/service/services/session.service.ts | 15 + 24 files changed, 597 insertions(+), 98 deletions(-) create mode 100644 packages/core/e2e/customer-channel.e2e-spec.ts diff --git a/packages/core/e2e/customer-channel.e2e-spec.ts b/packages/core/e2e/customer-channel.e2e-spec.ts new file mode 100644 index 0000000000..1c65663e38 --- /dev/null +++ b/packages/core/e2e/customer-channel.e2e-spec.ts @@ -0,0 +1,339 @@ +/* tslint:disable:no-non-null-assertion */ +import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing'; +import path from 'path'; + +import { initialData } from '../../../e2e-common/e2e-initial-data'; +import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config'; + +import { ADD_CUSTOMERS_TO_GROUP, GET_CUSTOMER_GROUP } from './customer-group.e2e-spec'; +import { CREATE_ADDRESS, CREATE_CUSTOMER, DELETE_CUSTOMER, UPDATE_ADDRESS } from './customer.e2e-spec'; +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, + GET_CUSTOMER_LIST, + ME, + REMOVE_CUSTOMERS_FROM_GROUP, +} from './graphql/shared-definitions'; +import { DELETE_ADDRESS, REGISTER_ACCOUNT, UPDATE_CUSTOMER } 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( + GET_CUSTOMER_LIST, + { + options: { take: numberOfCustomers }, + }, + ); + firstCustomer = customers.items[0]; + secondCustomer = customers.items[1]; + thirdCustomer = customers.items[2]; + + await adminClient.query(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(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(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( + 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(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(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(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( + 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( + 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(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); + + adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + const { customers } = await adminClient.query( + 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_ACCOUNT, { + input: { + emailAddress: 'john.doe.2@test.com', + }, + }); + + adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + const { customers } = await adminClient.query( + 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( + 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( + 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( + REMOVE_CUSTOMERS_FROM_GROUP, + { + groupId: customerGroupId, + customerIds: [thirdCustomer.id], + }, + ); + }); + }); +}); diff --git a/packages/core/e2e/customer.e2e-spec.ts b/packages/core/e2e/customer.e2e-spec.ts index 43c0a80a4f..dc8b70d0fa 100644 --- a/packages/core/e2e/customer.e2e-spec.ts +++ b/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 diff --git a/packages/core/src/api/middleware/auth-guard.ts b/packages/core/src/api/middleware/auth-guard.ts index 77c7241d01..742c9b98a2 100644 --- a/packages/core/src/api/middleware/auth-guard.ts +++ b/packages/core/src/api/middleware/auth-guard.ts @@ -6,6 +6,9 @@ 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'; @@ -26,6 +29,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 { @@ -38,6 +43,23 @@ export class AuthGuard implements CanActivate { const requestContext = await this.requestContextService.fromRequest(req, info, permissions, session); (req as any)[REQUEST_CONTEXT_KEY] = requestContext; + if (session && (!session.activeChannelId || session.activeChannelId !== requestContext.channelId)) { + await this.sessionService.setActiveChannel(session, requestContext.channel); + if (requestContext.activeUserId) { + const customer = await this.customerService.findOneByUserId( + requestContext, + requestContext.activeUserId, + false, + ); + if (customer) { + await this.channelService.assignToChannels(Customer, customer.id, [ + requestContext.channelId, + ]); + } + } + return this.canActivate(context); + } + if (authDisabled || !permissions || isPublic) { return true; } else { diff --git a/packages/core/src/api/resolvers/admin/customer.resolver.ts b/packages/core/src/api/resolvers/admin/customer.resolver.ts index 63c7b5dd4c..77cf17cf0c 100644 --- a/packages/core/src/api/resolvers/admin/customer.resolver.ts +++ b/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> { - return this.customerService.findAll(args.options || undefined); + async customers( + @Ctx() ctx: RequestContext, + @Args() args: QueryCustomersArgs, + ): Promise> { + return this.customerService.findAll(ctx, args.options || undefined); } @Query() @Allow(Permission.ReadCustomer) - async customer(@Args() args: QueryCustomerArgs): Promise { - return this.customerService.findOne(args.id); + async customer( + @Ctx() ctx: RequestContext, + @Args() args: QueryCustomerArgs, + ): Promise { + 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 { - return this.customerService.softDelete(args.id); + async deleteCustomer( + @Ctx() ctx: RequestContext, + @Args() args: MutationDeleteCustomerArgs, + ): Promise { + return this.customerService.softDelete(ctx, args.id); } @Mutation() diff --git a/packages/core/src/api/resolvers/entity/customer-entity.resolver.ts b/packages/core/src/api/resolvers/entity/customer-entity.resolver.ts index 233292825b..8d37d35f2c 100644 --- a/packages/core/src/api/resolvers/entity/customer-entity.resolver.ts +++ b/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() diff --git a/packages/core/src/api/resolvers/entity/customer-group-entity.resolver.ts b/packages/core/src/api/resolvers/entity/customer-group-entity.resolver.ts index 01a90465f7..f1489138b2 100644 --- a/packages/core/src/api/resolvers/entity/customer-group-entity.resolver.ts +++ b/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> { - return this.customerGroupService.getGroupCustomers(customerGroup.id, args.options || undefined); + return this.customerGroupService.getGroupCustomers(ctx, customerGroup.id, args.options || undefined); } } diff --git a/packages/core/src/api/resolvers/shop/shop-auth.resolver.ts b/packages/core/src/api/resolvers/shop/shop-auth.resolver.ts index af5d83921f..be99b68a30 100644 --- a/packages/core/src/api/resolvers/shop/shop-auth.resolver.ts +++ b/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' + diff --git a/packages/core/src/api/resolvers/shop/shop-customer.resolver.ts b/packages/core/src/api/resolvers/shop/shop-customer.resolver.ts index c3861cf50f..0824426906 100644 --- a/packages/core/src/api/resolvers/shop/shop-customer.resolver.ts +++ b/packages/core/src/api/resolvers/shop/shop-customer.resolver.ts @@ -24,7 +24,7 @@ export class ShopCustomerResolver { async activeCustomer(@Ctx() ctx: RequestContext): Promise { 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`); } diff --git a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts b/packages/core/src/api/resolvers/shop/shop-order.resolver.ts index 65ebf5bb9b..c6449a9a03 100644 --- a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts +++ b/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); } } diff --git a/packages/core/src/config/promotion/conditions/contains-products-condition.ts b/packages/core/src/config/promotion/conditions/contains-products-condition.ts index 0fe1c93d0d..fad0010c90 100644 --- a/packages/core/src/config/promotion/conditions/contains-products-condition.ts +++ b/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) { diff --git a/packages/core/src/config/promotion/conditions/customer-group-condition.ts b/packages/core/src/config/promotion/conditions/customer-group-condition.ts index 3c4985fae3..040e88bace 100644 --- a/packages/core/src/config/promotion/conditions/customer-group-condition.ts +++ b/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,14 +27,14 @@ 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); + const groups = await customerService.getCustomerGroups(ctx, customerId); groupIds = groups.map(g => g.id); cache.set(customerId, groupIds); } diff --git a/packages/core/src/config/promotion/conditions/has-facet-values-condition.ts b/packages/core/src/config/promotion/conditions/has-facet-values-condition.ts index a6e740aa35..f9cb68a1b1 100644 --- a/packages/core/src/config/promotion/conditions/has-facet-values-condition.ts +++ b/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)) { diff --git a/packages/core/src/config/promotion/conditions/min-order-amount-condition.ts b/packages/core/src/config/promotion/conditions/min-order-amount-condition.ts index f7b0c43867..895bfba1b3 100644 --- a/packages/core/src/config/promotion/conditions/min-order-amount-condition.ts +++ b/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 { diff --git a/packages/core/src/config/promotion/promotion-condition.ts b/packages/core/src/config/promotion/promotion-condition.ts index d6e1b7f6eb..501a5ebf5b 100644 --- a/packages/core/src/config/promotion/promotion-condition.ts +++ b/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 = ( + ctx: RequestContext, order: Order, args: ConfigArgValues, ) => boolean | Promise; @@ -61,7 +63,7 @@ export class PromotionCondition extends Confi this.priorityValue = config.priorityValue || 0; } - async check(order: Order, args: ConfigArg[]): Promise { - return this.checkFn(order, this.argsArrayToHash(args)); + async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise { + return this.checkFn(ctx, order, this.argsArrayToHash(args)); } } diff --git a/packages/core/src/config/session-cache/session-cache-strategy.ts b/packages/core/src/config/session-cache/session-cache-strategy.ts index e18615dcde..d9c50d85c2 100644 --- a/packages/core/src/config/session-cache/session-cache-strategy.ts +++ b/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; }; /** diff --git a/packages/core/src/entity/customer/customer.entity.ts b/packages/core/src/entity/customer/customer.entity.ts index ca21414175..bf8f7c06eb 100644 --- a/packages/core/src/entity/customer/customer.entity.ts +++ b/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) { 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[]; } diff --git a/packages/core/src/entity/promotion/promotion.entity.ts b/packages/core/src/entity/promotion/promotion.entity.ts index 0f08a5a0d0..03ab4169e9 100644 --- a/packages/core/src/entity/promotion/promotion.entity.ts +++ b/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 { + async test(ctx: RequestContext, order: Order): Promise { 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; } } diff --git a/packages/core/src/entity/session/session.entity.ts b/packages/core/src/entity/session/session.entity.ts index 471eff909c..9954f2a55c 100644 --- a/packages/core/src/entity/session/session.entity.ts +++ b/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; } diff --git a/packages/core/src/service/helpers/external-authentication/external-authentication.service.ts b/packages/core/src/service/helpers/external-authentication/external-authentication.service.ts index b149ca698d..86da0a2b6f 100644 --- a/packages/core/src/service/helpers/external-authentication/external-authentication.service.ts +++ b/packages/core/src/service/helpers/external-authentication/external-authentication.service.ts @@ -36,12 +36,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 { + async findCustomerUser( + ctx: RequestContext, + strategy: string, + externalIdentifier: string, + ): Promise { 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; } diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.ts index 6b26751368..377037d6dc 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/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 { - const updatedItems = await this.applyOrderItemPromotions(order, promotions); - await this.applyOrderPromotions(order, promotions); + private async applyPromotions( + ctx: RequestContext, + order: Order, + promotions: Promotion[], + ): Promise { + 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); diff --git a/packages/core/src/service/services/customer-group.service.ts b/packages/core/src/service/services/customer-group.service.ts index 7fa02f91d1..b310bb0c3a 100644 --- a/packages/core/src/service/services/customer-group.service.ts +++ b/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> { + getGroupCustomers( + ctx: RequestContext, + customerGroupId: ID, + options?: CustomerListOptions, + ): Promise> { 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 { - 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 { - 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 { + private getCustomersFromIds(ctx: RequestContext, ids: ID[]): Promise | Customer[] { + if (ids.length === 0) { + return new Array(); + } // 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(); } } diff --git a/packages/core/src/service/services/customer.service.ts b/packages/core/src/service/services/customer.service.ts index 6639362cbb..6d75252645 100644 --- a/packages/core/src/service/services/customer.service.ts +++ b/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 | undefined): Promise> { + findAll( + ctx: RequestContext, + options: ListQueryOptions | undefined, + ): Promise> { 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 { - return this.connection.getRepository(Customer).findOne(id, { where: { deletedAt: null } }); - } - - findOneByUserId(userId: ID): Promise { - return this.connection.getRepository(Customer).findOne({ + findOne(ctx: RequestContext, id: ID): Promise { + return findOneInChannel(this.connection, Customer, id, ctx.channelId, { where: { - user: { id: userId }, deletedAt: null, }, }); } - findAddressesByCustomerId(ctx: RequestContext, customerId: ID): Promise { + findOneByUserId(ctx: RequestContext, userId: ID, filterOnChannel = true): Promise { + 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 { + 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 { - const customerWithGroups = await this.connection - .getRepository(Customer) - .findOne(customerId, { relations: ['groups'] }); + async getCustomerGroups(ctx: RequestContext, customerId: ID): Promise { + 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 { 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 { 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 { - 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 & { emailAddress: string }, throwOnExistingUser: boolean = false, ): Promise { 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
{ - 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 { - const customer = await getEntityOrThrow(this.connection, Customer, customerId); + async softDelete(ctx: RequestContext, customerId: ID): Promise { + 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 { - 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, diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index 9d0e9f48a7..d3fd8f0464 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -217,7 +217,7 @@ export class OrderService { } async getActiveOrderForUser(ctx: RequestContext, userId: ID): Promise { - 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') @@ -249,7 +249,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; } @@ -814,7 +814,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 }); diff --git a/packages/core/src/service/services/session.service.ts b/packages/core/src/service/services/session.service.ts index ee72aa3afc..45f82790da 100644 --- a/packages/core/src/service/services/session.service.ts +++ b/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 { + 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. */ From 33776d6c25bce35cd9f15defc30a6e96f9850bb3 Mon Sep 17 00:00:00 2001 From: Hendrik Depauw Date: Sat, 5 Sep 2020 18:04:00 +0200 Subject: [PATCH 2/4] test(core): Fix unit tests for ChannelAware Customers --- .../service/helpers/order-calculator/order-calculator.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts index 950f1bf513..b86d135b2e 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts +++ b/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; From 970b6d8d36bea86d6e1246c911f4298e30d1dde1 Mon Sep 17 00:00:00 2001 From: hendrikdepauw Date: Wed, 9 Sep 2020 23:47:51 +0200 Subject: [PATCH 3/4] fix(core): Finalize ChannelAware Customers implementation --- .../core/e2e/customer-channel.e2e-spec.ts | 154 +++++++++++++++++- .../core/src/api/middleware/auth-guard.ts | 9 +- .../external-authentication.service.ts | 18 +- 3 files changed, 168 insertions(+), 13 deletions(-) diff --git a/packages/core/e2e/customer-channel.e2e-spec.ts b/packages/core/e2e/customer-channel.e2e-spec.ts index 1c65663e38..e344b7d312 100644 --- a/packages/core/e2e/customer-channel.e2e-spec.ts +++ b/packages/core/e2e/customer-channel.e2e-spec.ts @@ -1,12 +1,12 @@ /* 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 { ADD_CUSTOMERS_TO_GROUP, GET_CUSTOMER_GROUP } from './customer-group.e2e-spec'; -import { CREATE_ADDRESS, CREATE_CUSTOMER, DELETE_CUSTOMER, UPDATE_ADDRESS } from './customer.e2e-spec'; +import { CUSTOMER_FRAGMENT } from './graphql/fragments'; import { AddCustomersToGroup, CreateAddress, @@ -28,11 +28,12 @@ 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, UPDATE_CUSTOMER } from './graphql/shop-definitions'; +import { DELETE_ADDRESS, REGISTER_ACCOUNT } from './graphql/shop-definitions'; import { assertThrowsWithMessage } from './utils/assert-throws-with-message'; describe('ChannelAware Customers', () => { @@ -337,3 +338,150 @@ describe('ChannelAware Customers', () => { }); }); }); + +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 + } + } + } +`; diff --git a/packages/core/src/api/middleware/auth-guard.ts b/packages/core/src/api/middleware/auth-guard.ts index 742c9b98a2..ed855a4c0e 100644 --- a/packages/core/src/api/middleware/auth-guard.ts +++ b/packages/core/src/api/middleware/auth-guard.ts @@ -40,9 +40,11 @@ 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); (req as any)[REQUEST_CONTEXT_KEY] = requestContext; + // 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 if (session && (!session.activeChannelId || session.activeChannelId !== requestContext.channelId)) { await this.sessionService.setActiveChannel(session, requestContext.channel); if (requestContext.activeUserId) { @@ -51,13 +53,16 @@ export class AuthGuard implements CanActivate { 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 this.canActivate(context); + requestContext = await this.requestContextService.fromRequest(req, info, permissions, session); + (req as any)[REQUEST_CONTEXT_KEY] = requestContext; } if (authDisabled || !permissions || isPublic) { diff --git a/packages/core/src/service/helpers/external-authentication/external-authentication.service.ts b/packages/core/src/service/helpers/external-authentication/external-authentication.service.ts index 86da0a2b6f..61cbee7189 100644 --- a/packages/core/src/service/helpers/external-authentication/external-authentication.service.ts +++ b/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, ) {} /** @@ -103,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, From 35b7f74d734d96aea9ee53e261e037cb2cbf24de Mon Sep 17 00:00:00 2001 From: hendrikdepauw Date: Sun, 13 Sep 2020 11:03:54 +0200 Subject: [PATCH 4/4] fix(core): Implement review comments --- .../core/src/api/middleware/auth-guard.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/core/src/api/middleware/auth-guard.ts b/packages/core/src/api/middleware/auth-guard.ts index ed855a4c0e..3f628feb87 100644 --- a/packages/core/src/api/middleware/auth-guard.ts +++ b/packages/core/src/api/middleware/auth-guard.ts @@ -12,6 +12,7 @@ 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'; @@ -41,11 +42,37 @@ export class AuthGuard implements CanActivate { const hasOwnerPermission = !!permissions && permissions.includes(Permission.Owner); const session = await this.getSession(req, res, hasOwnerPermission); 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) { + return true; + } else { + const canActivate = requestContext.isAuthorized || requestContext.authorizedAsOwnerOnly; + if (!canActivate) { + throw new ForbiddenError(); + } else { + return canActivate; + } + } + } + + private async setActiveChannel( + requestContext: RequestContext, + session?: CachedSession, + ): Promise { + 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 - if (session && (!session.activeChannelId || session.activeChannelId !== requestContext.channelId)) { + 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( @@ -61,20 +88,9 @@ export class AuthGuard implements CanActivate { ]); } } - requestContext = await this.requestContextService.fromRequest(req, info, permissions, session); - (req as any)[REQUEST_CONTEXT_KEY] = requestContext; - } - - if (authDisabled || !permissions || isPublic) { return true; - } else { - const canActivate = requestContext.isAuthorized || requestContext.authorizedAsOwnerOnly; - if (!canActivate) { - throw new ForbiddenError(); - } else { - return canActivate; - } } + return false; } private async getSession(