diff --git a/packages/admin-ui/src/lib/core/src/common/generated-types.ts b/packages/admin-ui/src/lib/core/src/common/generated-types.ts index cb26e0f3db..5dc337d6a4 100644 --- a/packages/admin-ui/src/lib/core/src/common/generated-types.ts +++ b/packages/admin-ui/src/lib/core/src/common/generated-types.ts @@ -295,7 +295,9 @@ export type Mutation = { addNoteToOrder: Order; /** Add an OptionGroup to a Product */ addOptionGroupToProduct: Product; - /** Assigns Products to the specified Channel */ + /** Assigns ProductVariants to the specified Channel */ + assignProductVariantsToChannel: Array; + /** Assigns all ProductVariants of Product to the specified Channel */ assignProductsToChannel: Array; /** Assign a Role to an Administrator */ assignRoleToAdministrator: Administrator; @@ -394,7 +396,9 @@ export type Mutation = { removeMembersFromZone: Zone; /** Remove an OptionGroup from a Product */ removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult; - /** Removes Products from the specified Channel */ + /** Removes ProductVariants from the specified Channel */ + removeProductVariantsFromChannel: Array; + /** Removes all ProductVariants of Product from the specified Channel */ removeProductsFromChannel: Array; /** Remove all settled jobs in the given queues olfer than the given date. Returns the number of jobs deleted. */ removeSettledJobs: Scalars['Int']; @@ -492,6 +496,11 @@ export type MutationAddOptionGroupToProductArgs = { }; +export type MutationAssignProductVariantsToChannelArgs = { + input: AssignProductVariantsToChannelInput; +}; + + export type MutationAssignProductsToChannelArgs = { input: AssignProductsToChannelInput; }; @@ -765,6 +774,11 @@ export type MutationRemoveOptionGroupFromProductArgs = { }; +export type MutationRemoveProductVariantsFromChannelArgs = { + input: RemoveProductVariantsFromChannelInput; +}; + + export type MutationRemoveProductsFromChannelArgs = { input: RemoveProductsFromChannelInput; }; @@ -1677,6 +1691,7 @@ export type ProductVariant = Node & { outOfStockThreshold: Scalars['Int']; useGlobalOutOfStockThreshold: Scalars['Boolean']; stockMovements: StockMovementList; + channels: Array; id: Scalars['ID']; product: Product; productId: Scalars['ID']; @@ -1795,6 +1810,17 @@ export type RemoveProductsFromChannelInput = { channelId: Scalars['ID']; }; +export type AssignProductVariantsToChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; + priceFactor?: Maybe; +}; + +export type RemoveProductVariantsFromChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; +}; + export type ProductOptionInUseError = ErrorResult & { __typename?: 'ProductOptionInUseError'; errorCode: ErrorCode; diff --git a/packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts b/packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts index ffff7edf64..4af50c144d 100644 --- a/packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts +++ b/packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts @@ -362,10 +362,14 @@ export type Mutation = { updateProductVariants: Array>; /** Delete a ProductVariant */ deleteProductVariant: DeletionResponse; - /** Assigns Products to the specified Channel */ + /** Assigns all ProductVariants of Product to the specified Channel */ assignProductsToChannel: Array; - /** Removes Products from the specified Channel */ + /** Removes all ProductVariants of Product from the specified Channel */ removeProductsFromChannel: Array; + /** Assigns ProductVariants to the specified Channel */ + assignProductVariantsToChannel: Array; + /** Removes ProductVariants from the specified Channel */ + removeProductVariantsFromChannel: Array; createPromotion: CreatePromotionResult; updatePromotion: UpdatePromotionResult; deletePromotion: DeletionResponse; @@ -702,6 +706,14 @@ export type MutationRemoveProductsFromChannelArgs = { input: RemoveProductsFromChannelInput; }; +export type MutationAssignProductVariantsToChannelArgs = { + input: AssignProductVariantsToChannelInput; +}; + +export type MutationRemoveProductVariantsFromChannelArgs = { + input: RemoveProductVariantsFromChannelInput; +}; + export type MutationCreatePromotionArgs = { input: CreatePromotionInput; }; @@ -1503,6 +1515,7 @@ export type ProductVariant = Node & { outOfStockThreshold: Scalars['Int']; useGlobalOutOfStockThreshold: Scalars['Boolean']; stockMovements: StockMovementList; + channels: Array; id: Scalars['ID']; product: Product; productId: Scalars['ID']; @@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = { channelId: Scalars['ID']; }; +export type AssignProductVariantsToChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; + priceFactor?: Maybe; +}; + +export type RemoveProductVariantsFromChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; +}; + export type ProductOptionInUseError = ErrorResult & { errorCode: ErrorCode; message: Scalars['String']; diff --git a/packages/common/src/generated-types.ts b/packages/common/src/generated-types.ts index 3e4877ca39..4ccf9a9979 100644 --- a/packages/common/src/generated-types.ts +++ b/packages/common/src/generated-types.ts @@ -404,10 +404,14 @@ export type Mutation = { updateProductVariants: Array>; /** Delete a ProductVariant */ deleteProductVariant: DeletionResponse; - /** Assigns Products to the specified Channel */ + /** Assigns all ProductVariants of Product to the specified Channel */ assignProductsToChannel: Array; - /** Removes Products from the specified Channel */ + /** Removes all ProductVariants of Product from the specified Channel */ removeProductsFromChannel: Array; + /** Assigns ProductVariants to the specified Channel */ + assignProductVariantsToChannel: Array; + /** Removes ProductVariants from the specified Channel */ + removeProductVariantsFromChannel: Array; createPromotion: CreatePromotionResult; updatePromotion: UpdatePromotionResult; deletePromotion: DeletionResponse; @@ -815,6 +819,16 @@ export type MutationRemoveProductsFromChannelArgs = { }; +export type MutationAssignProductVariantsToChannelArgs = { + input: AssignProductVariantsToChannelInput; +}; + + +export type MutationRemoveProductVariantsFromChannelArgs = { + input: RemoveProductVariantsFromChannelInput; +}; + + export type MutationCreatePromotionArgs = { input: CreatePromotionInput; }; @@ -1646,6 +1660,7 @@ export type ProductVariant = Node & { outOfStockThreshold: Scalars['Int']; useGlobalOutOfStockThreshold: Scalars['Boolean']; stockMovements: StockMovementList; + channels: Array; id: Scalars['ID']; product: Product; productId: Scalars['ID']; @@ -1764,6 +1779,17 @@ export type RemoveProductsFromChannelInput = { channelId: Scalars['ID']; }; +export type AssignProductVariantsToChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; + priceFactor?: Maybe; +}; + +export type RemoveProductVariantsFromChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; +}; + export type ProductOptionInUseError = ErrorResult & { __typename?: 'ProductOptionInUseError'; errorCode: ErrorCode; diff --git a/packages/core/e2e/channel.e2e-spec.ts b/packages/core/e2e/channel.e2e-spec.ts index be0bd0853a..de20de48fd 100644 --- a/packages/core/e2e/channel.e2e-spec.ts +++ b/packages/core/e2e/channel.e2e-spec.ts @@ -28,7 +28,6 @@ import { LanguageCode, Me, Permission, - RemoveProductsFromChannel, UpdateChannel, UpdateGlobalLanguages, } from './graphql/generated-e2e-admin-types'; @@ -40,7 +39,6 @@ import { GET_CUSTOMER_LIST, GET_PRODUCT_WITH_VARIANTS, ME, - REMOVE_PRODUCT_FROM_CHANNEL, UPDATE_CHANNEL, } from './graphql/shared-definitions'; import { assertThrowsWithMessage } from './utils/assert-throws-with-message'; @@ -48,7 +46,6 @@ import { assertThrowsWithMessage } from './utils/assert-throws-with-message'; describe('Channels', () => { const { server, adminClient, shopClient } = createTestEnvironment(testConfig); const SECOND_CHANNEL_TOKEN = 'second_channel_token'; - const THIRD_CHANNEL_TOKEN = 'third_channel_token'; let secondChannelAdminRole: CreateRole.CreateRole; let customerUser: GetCustomerList.Items; @@ -272,129 +269,10 @@ describe('Channels', () => { ]); }); - describe('assigning Product to Channels', () => { - let product1: GetProductWithVariants.Product; - - beforeAll(async () => { - await adminClient.asSuperAdmin(); - await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); - await adminClient.query(CREATE_CHANNEL, { - input: { - code: 'third-channel', - token: THIRD_CHANNEL_TOKEN, - defaultLanguageCode: LanguageCode.en, - currencyCode: CurrencyCode.GBP, - pricesIncludeTax: true, - defaultShippingZoneId: 'T_1', - defaultTaxZoneId: 'T_1', - }, - }); - - const { product } = await adminClient.query< - GetProductWithVariants.Query, - GetProductWithVariants.Variables - >(GET_PRODUCT_WITH_VARIANTS, { - id: 'T_1', - }); - product1 = product!; - }); - - it( - 'throws if attempting to assign Product to channel to which the admin has no access', - assertThrowsWithMessage(async () => { - await adminClient.asUserWithCredentials('admin2@test.com', 'test'); - await adminClient.query( - ASSIGN_PRODUCT_TO_CHANNEL, - { - input: { - channelId: 'T_3', - productIds: [product1.id], - }, - }, - ); - }, 'You are not currently authorized to perform this action'), - ); - - it('assigns Product to Channel and applies price factor', async () => { - const PRICE_FACTOR = 0.5; - await adminClient.asSuperAdmin(); - const { assignProductsToChannel } = await adminClient.query< - AssignProductsToChannel.Mutation, - AssignProductsToChannel.Variables - >(ASSIGN_PRODUCT_TO_CHANNEL, { - input: { - channelId: 'T_2', - productIds: [product1.id], - priceFactor: PRICE_FACTOR, - }, - }); - - expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']); - await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); - const { product } = await adminClient.query< - GetProductWithVariants.Query, - GetProductWithVariants.Variables - >(GET_PRODUCT_WITH_VARIANTS, { - id: product1.id, - }); - - expect(product!.variants.map(v => v.price)).toEqual( - product1.variants.map(v => v.price * PRICE_FACTOR), - ); - // Second Channel is configured to include taxes in price, so they should be the same. - expect(product!.variants.map(v => v.priceWithTax)).toEqual( - product1.variants.map(v => v.price * PRICE_FACTOR), - ); - }); - - it('does not assign Product to same channel twice', async () => { - const { assignProductsToChannel } = await adminClient.query< - AssignProductsToChannel.Mutation, - AssignProductsToChannel.Variables - >(ASSIGN_PRODUCT_TO_CHANNEL, { - input: { - channelId: 'T_2', - productIds: [product1.id], - }, - }); - - expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']); - }); - - it( - 'throws if attempting to remove Product from default Channel', - assertThrowsWithMessage(async () => { - await adminClient.query< - RemoveProductsFromChannel.Mutation, - RemoveProductsFromChannel.Variables - >(REMOVE_PRODUCT_FROM_CHANNEL, { - input: { - productIds: [product1.id], - channelId: 'T_1', - }, - }); - }, 'Products cannot be removed from the default Channel'), - ); - - it('removes Product from Channel', async () => { - await adminClient.asSuperAdmin(); - await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); - const { removeProductsFromChannel } = await adminClient.query< - RemoveProductsFromChannel.Mutation, - RemoveProductsFromChannel.Variables - >(REMOVE_PRODUCT_FROM_CHANNEL, { - input: { - productIds: [product1.id], - channelId: 'T_2', - }, - }); - - expect(removeProductsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']); - }); - }); - describe('setting defaultLanguage', () => { it('returns error result if languageCode not in availableLanguages', async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); const { updateChannel } = await adminClient.query< UpdateChannel.Mutation, UpdateChannel.Variables @@ -414,6 +292,8 @@ describe('Channels', () => { }); it('allows setting to an available language', async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); await adminClient.query( UPDATE_GLOBAL_LANGUAGES, { @@ -439,6 +319,8 @@ describe('Channels', () => { it('deleteChannel', async () => { const PROD_ID = 'T_1'; + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); const { assignProductsToChannel } = await adminClient.query< AssignProductsToChannel.Mutation, @@ -461,7 +343,7 @@ describe('Channels', () => { expect(deleteChannel.result).toBe(DeletionResult.DELETED); const { channels } = await adminClient.query(GET_CHANNELS); - expect(channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']); + expect(channels.map(c => c.id).sort()).toEqual(['T_1']); const { product } = await adminClient.query< GetProductWithVariants.Query, diff --git a/packages/core/e2e/default-search-plugin.e2e-spec.ts b/packages/core/e2e/default-search-plugin.e2e-spec.ts index a93198da22..5a20cd4871 100644 --- a/packages/core/e2e/default-search-plugin.e2e-spec.ts +++ b/packages/core/e2e/default-search-plugin.e2e-spec.ts @@ -15,6 +15,7 @@ import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-conf import { AssignProductsToChannel, + AssignProductVariantsToChannel, ChannelFragment, CreateChannel, CreateCollection, @@ -26,6 +27,7 @@ import { LanguageCode, Reindex, RemoveProductsFromChannel, + RemoveProductVariantsFromChannel, SearchFacetValues, SearchGetAssets, SearchGetPrices, @@ -40,6 +42,7 @@ import { } from './graphql/generated-e2e-admin-types'; import { LogicalOperator, SearchProductsShop } from './graphql/generated-e2e-shop-types'; import { + ASSIGN_PRODUCTVARIANT_TO_CHANNEL, ASSIGN_PRODUCT_TO_CHANNEL, CREATE_CHANNEL, CREATE_COLLECTION, @@ -47,6 +50,7 @@ import { DELETE_ASSET, DELETE_PRODUCT, DELETE_PRODUCT_VARIANT, + REMOVE_PRODUCTVARIANT_FROM_CHANNEL, REMOVE_PRODUCT_FROM_CHANNEL, UPDATE_ASSET, UPDATE_COLLECTION, @@ -886,7 +890,7 @@ describe('Default search plugin', () => { defaultShippingZoneId: 'T_1', }, }); - secondChannel = createChannel; + secondChannel = createChannel as ChannelFragment; }); it('adding product to channel', async () => { @@ -921,6 +925,56 @@ describe('Default search plugin', () => { const { search } = await doAdminSearchQuery({ groupByProduct: true }); expect(search.items.map(i => i.productId)).toEqual(['T_1']); }, 10000); + + it('adding product variant to channel', async () => { + adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + await adminClient.query< + AssignProductVariantsToChannel.Mutation, + AssignProductVariantsToChannel.Variables + >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, { + input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] }, + }); + await awaitRunningJobs(adminClient); + + adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + + const { search: searchGrouped } = await doAdminSearchQuery({ groupByProduct: true }); + expect(searchGrouped.items.map(i => i.productId)).toEqual(['T_1', 'T_3', 'T_4']); + + const { search: searchUngrouped } = await doAdminSearchQuery({ groupByProduct: false }); + expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([ + 'T_1', + 'T_2', + 'T_3', + 'T_4', + 'T_10', + 'T_15', + ]); + }, 10000); + + it('removing product variant to channel', async () => { + adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + await adminClient.query< + RemoveProductVariantsFromChannel.Mutation, + RemoveProductVariantsFromChannel.Variables + >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, { + input: { channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15'] }, + }); + await awaitRunningJobs(adminClient); + + adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + + const { search: searchGrouped } = await doAdminSearchQuery({ groupByProduct: true }); + expect(searchGrouped.items.map(i => i.productId)).toEqual(['T_1', 'T_3']); + + const { search: searchUngrouped } = await doAdminSearchQuery({ groupByProduct: false }); + expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([ + 'T_2', + 'T_3', + 'T_4', + 'T_10', + ]); + }, 10000); }); describe('multiple language handling', () => { diff --git a/packages/core/e2e/graphql/fragments.ts b/packages/core/e2e/graphql/fragments.ts index 81710ab0fc..9e3a43ec57 100644 --- a/packages/core/e2e/graphql/fragments.ts +++ b/packages/core/e2e/graphql/fragments.ts @@ -80,6 +80,10 @@ export const PRODUCT_VARIANT_FRAGMENT = gql` languageCode name } + channels { + id + code + } } ${ASSET_FRAGMENT} `; diff --git a/packages/core/e2e/graphql/generated-e2e-admin-types.ts b/packages/core/e2e/graphql/generated-e2e-admin-types.ts index 93605eca15..a2c55e653c 100644 --- a/packages/core/e2e/graphql/generated-e2e-admin-types.ts +++ b/packages/core/e2e/graphql/generated-e2e-admin-types.ts @@ -362,10 +362,14 @@ export type Mutation = { updateProductVariants: Array>; /** Delete a ProductVariant */ deleteProductVariant: DeletionResponse; - /** Assigns Products to the specified Channel */ + /** Assigns all ProductVariants of Product to the specified Channel */ assignProductsToChannel: Array; - /** Removes Products from the specified Channel */ + /** Removes all ProductVariants of Product from the specified Channel */ removeProductsFromChannel: Array; + /** Assigns ProductVariants to the specified Channel */ + assignProductVariantsToChannel: Array; + /** Removes ProductVariants from the specified Channel */ + removeProductVariantsFromChannel: Array; createPromotion: CreatePromotionResult; updatePromotion: UpdatePromotionResult; deletePromotion: DeletionResponse; @@ -702,6 +706,14 @@ export type MutationRemoveProductsFromChannelArgs = { input: RemoveProductsFromChannelInput; }; +export type MutationAssignProductVariantsToChannelArgs = { + input: AssignProductVariantsToChannelInput; +}; + +export type MutationRemoveProductVariantsFromChannelArgs = { + input: RemoveProductVariantsFromChannelInput; +}; + export type MutationCreatePromotionArgs = { input: CreatePromotionInput; }; @@ -1503,6 +1515,7 @@ export type ProductVariant = Node & { outOfStockThreshold: Scalars['Int']; useGlobalOutOfStockThreshold: Scalars['Boolean']; stockMovements: StockMovementList; + channels: Array; id: Scalars['ID']; product: Product; productId: Scalars['ID']; @@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = { channelId: Scalars['ID']; }; +export type AssignProductVariantsToChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; + priceFactor?: Maybe; +}; + +export type RemoveProductVariantsFromChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; +}; + export type ProductOptionInUseError = ErrorResult & { errorCode: ErrorCode; message: Scalars['String']; @@ -4533,6 +4557,7 @@ export type ProductVariantFragment = Pick< featuredAsset?: Maybe; assets: Array; translations: Array>; + channels: Array>; }; export type ProductWithVariantsFragment = Pick< @@ -4970,6 +4995,22 @@ export type RemoveProductsFromChannelMutation = { removeProductsFromChannel: Array; }; +export type AssignProductVariantsToChannelMutationVariables = Exact<{ + input: AssignProductVariantsToChannelInput; +}>; + +export type AssignProductVariantsToChannelMutation = { + assignProductVariantsToChannel: Array; +}; + +export type RemoveProductVariantsFromChannelMutationVariables = Exact<{ + input: RemoveProductVariantsFromChannelInput; +}>; + +export type RemoveProductVariantsFromChannelMutation = { + removeProductVariantsFromChannel: Array; +}; + export type UpdateAssetMutationVariables = Exact<{ input: UpdateAssetInput; }>; @@ -6426,6 +6467,7 @@ export namespace ProductVariant { export type FeaturedAsset = NonNullable; export type Assets = NonNullable[number]>; export type Translations = NonNullable[number]>; + export type Channels = NonNullable[number]>; } export namespace ProductWithVariants { @@ -6855,6 +6897,22 @@ export namespace RemoveProductsFromChannel { >; } +export namespace AssignProductVariantsToChannel { + export type Variables = AssignProductVariantsToChannelMutationVariables; + export type Mutation = AssignProductVariantsToChannelMutation; + export type AssignProductVariantsToChannel = NonNullable< + NonNullable[number] + >; +} + +export namespace RemoveProductVariantsFromChannel { + export type Variables = RemoveProductVariantsFromChannelMutationVariables; + export type Mutation = RemoveProductVariantsFromChannelMutation; + export type RemoveProductVariantsFromChannel = NonNullable< + NonNullable[number] + >; +} + export namespace UpdateAsset { export type Variables = UpdateAssetMutationVariables; export type Mutation = UpdateAssetMutation; diff --git a/packages/core/e2e/graphql/shared-definitions.ts b/packages/core/e2e/graphql/shared-definitions.ts index 21a70cbb45..e783daa7a0 100644 --- a/packages/core/e2e/graphql/shared-definitions.ts +++ b/packages/core/e2e/graphql/shared-definitions.ts @@ -351,6 +351,24 @@ export const REMOVE_PRODUCT_FROM_CHANNEL = gql` ${PRODUCT_WITH_VARIANTS_FRAGMENT} `; +export const ASSIGN_PRODUCTVARIANT_TO_CHANNEL = gql` + mutation AssignProductVariantsToChannel($input: AssignProductVariantsToChannelInput!) { + assignProductVariantsToChannel(input: $input) { + ...ProductVariant + } + } + ${PRODUCT_VARIANT_FRAGMENT} +`; + +export const REMOVE_PRODUCTVARIANT_FROM_CHANNEL = gql` + mutation RemoveProductVariantsFromChannel($input: RemoveProductVariantsFromChannelInput!) { + removeProductVariantsFromChannel(input: $input) { + ...ProductVariant + } + } + ${PRODUCT_VARIANT_FRAGMENT} +`; + export const UPDATE_ASSET = gql` mutation UpdateAsset($input: UpdateAssetInput!) { updateAsset(input: $input) { diff --git a/packages/core/e2e/product-channel.e2e-spec.ts b/packages/core/e2e/product-channel.e2e-spec.ts new file mode 100644 index 0000000000..6315c13e7d --- /dev/null +++ b/packages/core/e2e/product-channel.e2e-spec.ts @@ -0,0 +1,369 @@ +/* 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 { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; + +import { + AssignProductsToChannel, + AssignProductVariantsToChannel, + CreateAdministrator, + CreateChannel, + CreateRole, + CurrencyCode, + GetProductWithVariants, + LanguageCode, + Permission, + RemoveProductsFromChannel, + RemoveProductVariantsFromChannel, +} from './graphql/generated-e2e-admin-types'; +import { + ASSIGN_PRODUCTVARIANT_TO_CHANNEL, + ASSIGN_PRODUCT_TO_CHANNEL, + CREATE_ADMINISTRATOR, + CREATE_CHANNEL, + CREATE_ROLE, + GET_PRODUCT_WITH_VARIANTS, + REMOVE_PRODUCTVARIANT_FROM_CHANNEL, + REMOVE_PRODUCT_FROM_CHANNEL, +} from './graphql/shared-definitions'; +import { assertThrowsWithMessage } from './utils/assert-throws-with-message'; + +describe('ChannelAware Products and ProductVariants', () => { + const { server, adminClient, shopClient } = createTestEnvironment(testConfig); + const SECOND_CHANNEL_TOKEN = 'second_channel_token'; + const THIRD_CHANNEL_TOKEN = 'third_channel_token'; + let secondChannelAdminRole: CreateRole.CreateRole; + + beforeAll(async () => { + await server.init({ + initialData, + productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'), + customerCount: 1, + }); + await adminClient.asSuperAdmin(); + + await adminClient.query(CREATE_CHANNEL, { + input: { + code: 'second-channel', + token: SECOND_CHANNEL_TOKEN, + defaultLanguageCode: LanguageCode.en, + currencyCode: CurrencyCode.USD, + pricesIncludeTax: true, + defaultShippingZoneId: 'T_1', + defaultTaxZoneId: 'T_1', + }, + }); + + await adminClient.query(CREATE_CHANNEL, { + input: { + code: 'third-channel', + token: THIRD_CHANNEL_TOKEN, + defaultLanguageCode: LanguageCode.en, + currencyCode: CurrencyCode.USD, + pricesIncludeTax: true, + defaultShippingZoneId: 'T_1', + defaultTaxZoneId: 'T_1', + }, + }); + + const { createRole } = await adminClient.query( + CREATE_ROLE, + { + input: { + description: 'second channel admin', + code: 'second-channel-admin', + channelIds: ['T_2'], + permissions: [ + Permission.ReadCatalog, + Permission.ReadSettings, + Permission.ReadAdministrator, + Permission.CreateAdministrator, + Permission.UpdateAdministrator, + ], + }, + }, + ); + secondChannelAdminRole = createRole; + + await adminClient.query( + CREATE_ADMINISTRATOR, + { + input: { + firstName: 'Admin', + lastName: 'Two', + emailAddress: 'admin2@test.com', + password: 'test', + roleIds: [secondChannelAdminRole.id], + }, + }, + ); + }, TEST_SETUP_TIMEOUT_MS); + + afterAll(async () => { + await server.destroy(); + }); + + describe('assigning Product to Channels', () => { + let product1: GetProductWithVariants.Product; + + beforeAll(async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + + const { product } = await adminClient.query< + GetProductWithVariants.Query, + GetProductWithVariants.Variables + >(GET_PRODUCT_WITH_VARIANTS, { + id: 'T_1', + }); + product1 = product!; + }); + + it( + 'throws if attempting to assign Product to channel to which the admin has no access', + assertThrowsWithMessage(async () => { + await adminClient.asUserWithCredentials('admin2@test.com', 'test'); + await adminClient.query( + ASSIGN_PRODUCT_TO_CHANNEL, + { + input: { + channelId: 'T_3', + productIds: [product1.id], + }, + }, + ); + }, 'You are not currently authorized to perform this action'), + ); + + it('assigns Product to Channel and applies price factor', async () => { + const PRICE_FACTOR = 0.5; + await adminClient.asSuperAdmin(); + const { assignProductsToChannel } = await adminClient.query< + AssignProductsToChannel.Mutation, + AssignProductsToChannel.Variables + >(ASSIGN_PRODUCT_TO_CHANNEL, { + input: { + channelId: 'T_2', + productIds: [product1.id], + priceFactor: PRICE_FACTOR, + }, + }); + + expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']); + await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + const { product } = await adminClient.query< + GetProductWithVariants.Query, + GetProductWithVariants.Variables + >(GET_PRODUCT_WITH_VARIANTS, { + id: product1.id, + }); + + expect(product!.variants.map(v => v.price)).toEqual( + product1.variants.map(v => v.price * PRICE_FACTOR), + ); + // Second Channel is configured to include taxes in price, so they should be the same. + expect(product!.variants.map(v => v.priceWithTax)).toEqual( + product1.variants.map(v => v.price * PRICE_FACTOR), + ); + }); + + it('does not assign Product to same channel twice', async () => { + const { assignProductsToChannel } = await adminClient.query< + AssignProductsToChannel.Mutation, + AssignProductsToChannel.Variables + >(ASSIGN_PRODUCT_TO_CHANNEL, { + input: { + channelId: 'T_2', + productIds: [product1.id], + }, + }); + + expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']); + }); + + it( + 'throws if attempting to remove Product from default Channel', + assertThrowsWithMessage(async () => { + await adminClient.query< + RemoveProductsFromChannel.Mutation, + RemoveProductsFromChannel.Variables + >(REMOVE_PRODUCT_FROM_CHANNEL, { + input: { + productIds: [product1.id], + channelId: 'T_1', + }, + }); + }, 'Products cannot be removed from the default Channel'), + ); + + it('removes Product from Channel', async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + const { removeProductsFromChannel } = await adminClient.query< + RemoveProductsFromChannel.Mutation, + RemoveProductsFromChannel.Variables + >(REMOVE_PRODUCT_FROM_CHANNEL, { + input: { + productIds: [product1.id], + channelId: 'T_2', + }, + }); + + expect(removeProductsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']); + }); + }); + + describe('assigning ProductVariant to Channels', () => { + let product1: GetProductWithVariants.Product; + + beforeAll(async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + + const { product } = await adminClient.query< + GetProductWithVariants.Query, + GetProductWithVariants.Variables + >(GET_PRODUCT_WITH_VARIANTS, { + id: 'T_2', + }); + product1 = product!; + }); + + it( + 'throws if attempting to assign ProductVariant to channel to which the admin has no access', + assertThrowsWithMessage(async () => { + await adminClient.asUserWithCredentials('admin2@test.com', 'test'); + await adminClient.query< + AssignProductVariantsToChannel.Mutation, + AssignProductVariantsToChannel.Variables + >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, { + input: { + channelId: 'T_3', + productVariantIds: [product1.variants[0].id], + }, + }); + }, 'You are not currently authorized to perform this action'), + ); + + it('assigns ProductVariant to Channel and applies price factor', async () => { + const PRICE_FACTOR = 0.5; + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + const { assignProductVariantsToChannel } = await adminClient.query< + AssignProductVariantsToChannel.Mutation, + AssignProductVariantsToChannel.Variables + >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, { + input: { + channelId: 'T_3', + productVariantIds: [product1.variants[0].id], + priceFactor: PRICE_FACTOR, + }, + }); + + expect(assignProductVariantsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']); + await adminClient.setChannelToken(THIRD_CHANNEL_TOKEN); + const { product } = await adminClient.query< + GetProductWithVariants.Query, + GetProductWithVariants.Variables + >(GET_PRODUCT_WITH_VARIANTS, { + id: product1.id, + }); + expect(product!.channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']); + expect(product!.variants.map(v => v.price)).toEqual([product1.variants[0].price * PRICE_FACTOR]); + // Third Channel is configured to include taxes in price, so they should be the same. + expect(product!.variants.map(v => v.priceWithTax)).toEqual([ + product1.variants[0].price * PRICE_FACTOR, + ]); + }); + + it('does not assign ProductVariant to same channel twice', async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + const { assignProductVariantsToChannel } = await adminClient.query< + AssignProductVariantsToChannel.Mutation, + AssignProductVariantsToChannel.Variables + >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, { + input: { + channelId: 'T_3', + productVariantIds: [product1.variants[0].id], + }, + }); + expect(assignProductVariantsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']); + }); + + it( + 'throws if attempting to remove ProductVariant from default Channel', + assertThrowsWithMessage(async () => { + await adminClient.query< + RemoveProductVariantsFromChannel.Mutation, + RemoveProductVariantsFromChannel.Variables + >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, { + input: { + productVariantIds: [product1.variants[0].id], + channelId: 'T_1', + }, + }); + }, 'Products cannot be removed from the default Channel'), + ); + + it('removes ProductVariant but not Product from Channel', async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + const { assignProductVariantsToChannel } = await adminClient.query< + AssignProductVariantsToChannel.Mutation, + AssignProductVariantsToChannel.Variables + >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, { + input: { + channelId: 'T_3', + productVariantIds: [product1.variants[1].id], + }, + }); + expect(assignProductVariantsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']); + + const { removeProductVariantsFromChannel } = await adminClient.query< + RemoveProductVariantsFromChannel.Mutation, + RemoveProductVariantsFromChannel.Variables + >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, { + input: { + productVariantIds: [product1.variants[1].id], + channelId: 'T_3', + }, + }); + expect(removeProductVariantsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']); + + const { product } = await adminClient.query< + GetProductWithVariants.Query, + GetProductWithVariants.Variables + >(GET_PRODUCT_WITH_VARIANTS, { + id: product1.id, + }); + expect(product!.channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']); + }); + + it('removes ProductVariant and Product from Channel', async () => { + await adminClient.asSuperAdmin(); + await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + const { removeProductVariantsFromChannel } = await adminClient.query< + RemoveProductVariantsFromChannel.Mutation, + RemoveProductVariantsFromChannel.Variables + >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, { + input: { + productVariantIds: [product1.variants[0].id], + channelId: 'T_3', + }, + }); + + expect(removeProductVariantsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']); + + const { product } = await adminClient.query< + GetProductWithVariants.Query, + GetProductWithVariants.Variables + >(GET_PRODUCT_WITH_VARIANTS, { + id: product1.id, + }); + expect(product!.channels.map(c => c.id).sort()).toEqual(['T_1']); + }); + }); +}); diff --git a/packages/core/src/api/resolvers/admin/product.resolver.ts b/packages/core/src/api/resolvers/admin/product.resolver.ts index 7394e87961..315ebd5eac 100644 --- a/packages/core/src/api/resolvers/admin/product.resolver.ts +++ b/packages/core/src/api/resolvers/admin/product.resolver.ts @@ -3,12 +3,14 @@ import { DeletionResponse, MutationAddOptionGroupToProductArgs, MutationAssignProductsToChannelArgs, + MutationAssignProductVariantsToChannelArgs, MutationCreateProductArgs, MutationCreateProductVariantsArgs, MutationDeleteProductArgs, MutationDeleteProductVariantArgs, MutationRemoveOptionGroupFromProductArgs, MutationRemoveProductsFromChannelArgs, + MutationRemoveProductVariantsFromChannelArgs, MutationUpdateProductArgs, MutationUpdateProductVariantsArgs, Permission, @@ -182,4 +184,24 @@ export class ProductResolver { ): Promise>> { return this.productService.removeProductsFromChannel(ctx, args.input); } + + @Transaction() + @Mutation() + @Allow(Permission.UpdateCatalog) + async assignProductVariantsToChannel( + @Ctx() ctx: RequestContext, + @Args() args: MutationAssignProductVariantsToChannelArgs, + ): Promise>> { + return this.productVariantService.assignProductVariantsToChannel(ctx, args.input); + } + + @Transaction() + @Mutation() + @Allow(Permission.UpdateCatalog) + async removeProductVariantsFromChannel( + @Ctx() ctx: RequestContext, + @Args() args: MutationRemoveProductVariantsFromChannelArgs, + ): Promise>> { + return this.productVariantService.removeProductVariantsFromChannel(ctx, args.input); + } } diff --git a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts index ea94e3704d..44164884e6 100644 --- a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts +++ b/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts @@ -3,7 +3,7 @@ import { StockMovementListOptions } from '@vendure/common/lib/generated-types'; import { PaginatedList } from '@vendure/common/lib/shared-types'; import { Translated } from '../../../common/types/locale-types'; -import { Asset, FacetValue, Product, ProductOption } from '../../../entity'; +import { Asset, Channel, FacetValue, Product, ProductOption } from '../../../entity'; import { ProductVariant } from '../../../entity/product-variant/product-variant.entity'; import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity'; import { AssetService } from '../../../service/services/asset.service'; @@ -80,7 +80,10 @@ export class ProductVariantEntityResolver { @Resolver('ProductVariant') export class ProductVariantAdminEntityResolver { - constructor(private stockMovementService: StockMovementService) {} + constructor( + private productVariantService: ProductVariantService, + private stockMovementService: StockMovementService, + ) {} @ResolveField() async stockMovements( @@ -94,4 +97,13 @@ export class ProductVariantAdminEntityResolver { args.options, ); } + + @ResolveField() + async channels(@Ctx() ctx: RequestContext, @Parent() productVariant: ProductVariant): Promise { + if (productVariant.channels) { + return productVariant.channels; + } else { + return this.productVariantService.getProductVariantChannels(ctx, productVariant.id); + } + } } diff --git a/packages/core/src/api/schema/admin-api/product.api.graphql b/packages/core/src/api/schema/admin-api/product.api.graphql index f0d2387dc3..07f5f3c901 100644 --- a/packages/core/src/api/schema/admin-api/product.api.graphql +++ b/packages/core/src/api/schema/admin-api/product.api.graphql @@ -31,11 +31,17 @@ type Mutation { "Delete a ProductVariant" deleteProductVariant(id: ID!): DeletionResponse! - "Assigns Products to the specified Channel" + "Assigns all ProductVariants of Product to the specified Channel" assignProductsToChannel(input: AssignProductsToChannelInput!): [Product!]! - "Removes Products from the specified Channel" + "Removes all ProductVariants of Product from the specified Channel" removeProductsFromChannel(input: RemoveProductsFromChannelInput!): [Product!]! + + "Assigns ProductVariants to the specified Channel" + assignProductVariantsToChannel(input: AssignProductVariantsToChannelInput!): [ProductVariant!]! + + "Removes ProductVariants from the specified Channel" + removeProductVariantsFromChannel(input: RemoveProductVariantsFromChannelInput!): [ProductVariant!]! } type Product implements Node { @@ -51,6 +57,7 @@ type ProductVariant implements Node { outOfStockThreshold: Int! useGlobalOutOfStockThreshold: Boolean! stockMovements(options: StockMovementListOptions): StockMovementList! + channels: [Channel!]! } input StockMovementListOptions { @@ -147,6 +154,17 @@ input RemoveProductsFromChannelInput { channelId: ID! } +input AssignProductVariantsToChannelInput { + productVariantIds: [ID!]! + channelId: ID! + priceFactor: Float +} + +input RemoveProductVariantsFromChannelInput { + productVariantIds: [ID!]! + channelId: ID! +} + type ProductOptionInUseError implements ErrorResult { errorCode: ErrorCode! message: String! diff --git a/packages/core/src/data-import/providers/importer/fast-importer.service.ts b/packages/core/src/data-import/providers/importer/fast-importer.service.ts index c986030384..72dec22127 100644 --- a/packages/core/src/data-import/providers/importer/fast-importer.service.ts +++ b/packages/core/src/data-import/providers/importer/fast-importer.service.ts @@ -119,6 +119,7 @@ export class FastImporterService { entityType: ProductVariant, translationType: ProductVariantTranslation, beforeSave: async variant => { + variant.channels = [this.defaultChannel]; const { optionIds } = input; if (optionIds && optionIds.length) { variant.options = optionIds.map(id => ({ id } as any)); diff --git a/packages/core/src/entity/product-variant/product-variant.entity.ts b/packages/core/src/entity/product-variant/product-variant.entity.ts index 90ce8dff51..66fbc4861b 100644 --- a/packages/core/src/entity/product-variant/product-variant.entity.ts +++ b/packages/core/src/entity/product-variant/product-variant.entity.ts @@ -2,11 +2,12 @@ import { CurrencyCode, GlobalFlag } from '@vendure/common/lib/generated-types'; import { DeepPartial, ID } from '@vendure/common/lib/shared-types'; import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; -import { SoftDeletable } from '../../common/types/common-types'; +import { ChannelAware, SoftDeletable } from '../../common/types/common-types'; import { LocaleString, Translatable, Translation } from '../../common/types/locale-types'; import { HasCustomFields } from '../../config/custom-field/custom-field-types'; import { Asset } from '../asset/asset.entity'; import { VendureEntity } from '../base/base.entity'; +import { Channel } from '../channel/channel.entity'; import { Collection } from '../collection/collection.entity'; import { CustomProductVariantFields } from '../custom-entity-fields'; import { EntityId } from '../entity-id.decorator'; @@ -31,7 +32,9 @@ import { ProductVariantTranslation } from './product-variant-translation.entity' * @docsCategory entities */ @Entity() -export class ProductVariant extends VendureEntity implements Translatable, HasCustomFields, SoftDeletable { +export class ProductVariant + extends VendureEntity + implements Translatable, HasCustomFields, SoftDeletable, ChannelAware { constructor(input?: DeepPartial) { super(input); } @@ -141,4 +144,8 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu @ManyToMany(type => Collection, collection => collection.productVariants) collections: Collection[]; + + @ManyToMany(type => Channel) + @JoinTable() + channels: Channel[]; } diff --git a/packages/core/src/entity/product/product.entity.ts b/packages/core/src/entity/product/product.entity.ts index dc178ca3b7..7a490d3375 100644 --- a/packages/core/src/entity/product/product.entity.ts +++ b/packages/core/src/entity/product/product.entity.ts @@ -23,7 +23,8 @@ import { ProductTranslation } from './product-translation.entity'; * @docsCategory entities */ @Entity() -export class Product extends VendureEntity +export class Product + extends VendureEntity implements Translatable, HasCustomFields, ChannelAware, SoftDeletable { constructor(input?: DeepPartial) { super(input); @@ -41,29 +42,29 @@ export class Product extends VendureEntity @Column({ default: true }) enabled: boolean; - @ManyToOne((type) => Asset, { onDelete: 'SET NULL' }) + @ManyToOne(type => Asset, { onDelete: 'SET NULL' }) featuredAsset: Asset; - @OneToMany((type) => ProductAsset, (productAsset) => productAsset.product) + @OneToMany(type => ProductAsset, productAsset => productAsset.product) assets: ProductAsset[]; - @OneToMany((type) => ProductTranslation, (translation) => translation.base, { eager: true }) + @OneToMany(type => ProductTranslation, translation => translation.base, { eager: true }) translations: Array>; - @OneToMany((type) => ProductVariant, (variant) => variant.product) + @OneToMany(type => ProductVariant, variant => variant.product) variants: ProductVariant[]; - @OneToMany((type) => ProductOptionGroup, (optionGroup) => optionGroup.product) + @OneToMany(type => ProductOptionGroup, optionGroup => optionGroup.product) optionGroups: ProductOptionGroup[]; - @ManyToMany((type) => FacetValue) + @ManyToMany(type => FacetValue) @JoinTable() facetValues: FacetValue[]; - @Column((type) => CustomProductFields) + @Column(type => CustomProductFields) customFields: CustomProductFields; - @ManyToMany((type) => Channel) + @ManyToMany(type => Channel) @JoinTable() channels: Channel[]; } diff --git a/packages/core/src/event-bus/events/product-variant-channel-event.ts b/packages/core/src/event-bus/events/product-variant-channel-event.ts new file mode 100644 index 0000000000..1614b15d5f --- /dev/null +++ b/packages/core/src/event-bus/events/product-variant-channel-event.ts @@ -0,0 +1,23 @@ +import { ID } from '@vendure/common/lib/shared-types'; + +import { RequestContext } from '../../api/common/request-context'; +import { ProductVariant } from '../../entity'; +import { VendureEvent } from '../vendure-event'; + +/** + * @description + * This event is fired whenever a {@link ProductVariant} is assigned or removed from a {@link Channel}. + * + * @docsCategory events + * @docsPage Event Types + */ +export class ProductVariantChannelEvent extends VendureEvent { + constructor( + public ctx: RequestContext, + public productVariant: ProductVariant, + public channelId: ID, + public type: 'assigned' | 'removed', + ) { + super(); + } +} diff --git a/packages/core/src/event-bus/index.ts b/packages/core/src/event-bus/index.ts index 752ee26e4b..d99440dfa8 100644 --- a/packages/core/src/event-bus/index.ts +++ b/packages/core/src/event-bus/index.ts @@ -18,5 +18,6 @@ export * from './events/payment-state-transition-event'; export * from './events/product-event'; export * from './events/product-channel-event'; export * from './events/product-variant-event'; +export * from './events/product-variant-channel-event'; export * from './events/refund-state-transition-event'; export * from './events/tax-rate-modification-event'; diff --git a/packages/core/src/job-queue/job.ts b/packages/core/src/job-queue/job.ts index 21a3dea868..5d869340df 100644 --- a/packages/core/src/job-queue/job.ts +++ b/packages/core/src/job-queue/job.ts @@ -132,7 +132,7 @@ export class Job = any> { * Sets the progress (0 - 100) of the job. */ setProgress(percent: number) { - this._progress = Math.min(percent, 100); + this._progress = Math.min(percent || 0, 100); this.fireEvent('progress'); } diff --git a/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts b/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts index bddb3af913..10e58a7eaf 100644 --- a/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts +++ b/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts @@ -8,6 +8,7 @@ import { AssetEvent } from '../../event-bus/events/asset-event'; import { CollectionModificationEvent } from '../../event-bus/events/collection-modification-event'; import { ProductChannelEvent } from '../../event-bus/events/product-channel-event'; import { ProductEvent } from '../../event-bus/events/product-event'; +import { ProductVariantChannelEvent } from '../../event-bus/events/product-variant-channel-event'; import { ProductVariantEvent } from '../../event-bus/events/product-variant-event'; import { TaxRateModificationEvent } from '../../event-bus/events/tax-rate-modification-event'; import { PluginCommonModule } from '../plugin-common.module'; @@ -106,6 +107,21 @@ export class DefaultSearchPlugin implements OnVendureBootstrap { ); } }); + this.eventBus.ofType(ProductVariantChannelEvent).subscribe(event => { + if (event.type === 'assigned') { + return this.searchIndexService.assignVariantToChannel( + event.ctx, + event.productVariant.id, + event.channelId, + ); + } else { + return this.searchIndexService.removeVariantFromChannel( + event.ctx, + event.productVariant.id, + event.channelId, + ); + } + }); const collectionModification$ = this.eventBus.ofType(CollectionModificationEvent); const closingNotifier$ = collectionModification$.pipe(debounceTime(50)); diff --git a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts index d40d8249fb..d4b265f609 100644 --- a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts +++ b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts @@ -21,11 +21,13 @@ import { asyncObservable } from '../../../worker/async-observable'; import { SearchIndexItem } from '../search-index-item.entity'; import { AssignProductToChannelMessage, + AssignVariantToChannelMessage, DeleteAssetMessage, DeleteProductMessage, DeleteVariantMessage, ReindexMessage, RemoveProductFromChannelMessage, + RemoveVariantFromChannelMessage, UpdateAssetMessage, UpdateProductMessage, UpdateVariantMessage, @@ -44,6 +46,7 @@ export const variantRelations = [ 'facetValues.facet', 'collections', 'taxCategory', + 'channels', ]; export const workerLoggerCtx = 'DefaultSearchPlugin Worker'; @@ -198,6 +201,27 @@ export class IndexerController { }); } + @MessagePattern(AssignVariantToChannelMessage.pattern) + assignVariantToChannel( + data: AssignVariantToChannelMessage['data'], + ): Observable { + const ctx = RequestContext.deserialize(data.ctx); + return asyncObservable(async () => { + return this.updateVariantsInChannel(ctx, [data.productVariantId], data.channelId); + }); + } + + @MessagePattern(RemoveVariantFromChannelMessage.pattern) + removeVariantFromChannel( + data: RemoveVariantFromChannelMessage['data'], + ): Observable { + const ctx = RequestContext.deserialize(data.ctx); + return asyncObservable(async () => { + await this.removeSearchIndexItems(ctx.languageCode, data.channelId, [data.productVariantId]); + return true; + }); + } + @MessagePattern(UpdateAssetMessage.pattern) updateAsset(data: UpdateAssetMessage['data']): Observable { return asyncObservable(async () => { @@ -352,7 +376,7 @@ export class IndexerController { productVariantAssetId: v.featuredAsset ? v.featuredAsset.id : null, productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '', productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '', - channelIds: v.product.channels.map(c => c.id as string), + channelIds: v.channels.map(c => c.id as string), facetIds: this.getFacetIds(v), facetValueIds: this.getFacetValueIds(v), collectionIds: v.collections.map(c => c.id.toString()), diff --git a/packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts b/packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts index 59e713573a..f0be99fd99 100644 --- a/packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts +++ b/packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts @@ -14,12 +14,14 @@ import { WorkerMessage } from '../../../worker/types'; import { WorkerService } from '../../../worker/worker.service'; import { AssignProductToChannelMessage, + AssignVariantToChannelMessage, DeleteAssetMessage, DeleteProductMessage, DeleteVariantMessage, ReindexMessage, ReindexMessageResponse, RemoveProductFromChannelMessage, + RemoveVariantFromChannelMessage, UpdateAssetMessage, UpdateIndexQueueJobData, UpdateProductMessage, @@ -40,7 +42,7 @@ export class SearchIndexService { updateIndexQueue = this.jobService.createQueue({ name: 'update-search-index', concurrency: 1, - process: (job) => { + process: job => { const data = job.data; switch (data.type) { case 'reindex': @@ -74,6 +76,12 @@ export class SearchIndexService { case 'remove-product-from-channel': this.sendMessage(job, new RemoveProductFromChannelMessage(data)); break; + case 'assign-variant-to-channel': + this.sendMessage(job, new AssignVariantToChannelMessage(data)); + break; + case 'remove-variant-from-channel': + this.sendMessage(job, new RemoveVariantFromChannelMessage(data)); + break; default: assertNever(data); } @@ -90,7 +98,7 @@ export class SearchIndexService { } updateVariants(ctx: RequestContext, variants: ProductVariant[]) { - const variantIds = variants.map((v) => v.id); + const variantIds = variants.map(v => v.id); this.addJobToQueue({ type: 'update-variants', ctx: ctx.serialize(), variantIds }); } @@ -99,7 +107,7 @@ export class SearchIndexService { } deleteVariant(ctx: RequestContext, variants: ProductVariant[]) { - const variantIds = variants.map((v) => v.id); + const variantIds = variants.map(v => v.id); this.addJobToQueue({ type: 'delete-variant', ctx: ctx.serialize(), variantIds }); } @@ -133,6 +141,24 @@ export class SearchIndexService { }); } + assignVariantToChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) { + this.addJobToQueue({ + type: 'assign-variant-to-channel', + ctx: ctx.serialize(), + productVariantId, + channelId, + }); + } + + removeVariantFromChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) { + this.addJobToQueue({ + type: 'remove-variant-from-channel', + ctx: ctx.serialize(), + productVariantId, + channelId, + }); + } + private addJobToQueue(data: UpdateIndexQueueJobData) { if (updateIndexQueue) { return updateIndexQueue.add(data); @@ -142,7 +168,7 @@ export class SearchIndexService { private sendMessage(job: Job, message: WorkerMessage) { this.workerService.send(message).subscribe({ complete: () => job.complete(true), - error: (err) => { + error: err => { Logger.error(err); job.fail(err); }, @@ -160,7 +186,7 @@ export class SearchIndexService { } duration = response.duration; completed = response.completed; - const progress = Math.ceil((completed / total) * 100); + const progress = total === 0 ? 100 : Math.ceil((completed / total) * 100); job.setProgress(progress); }, complete: () => { diff --git a/packages/core/src/plugin/default-search-plugin/types.ts b/packages/core/src/plugin/default-search-plugin/types.ts index 7993311abc..e4b9ad9412 100644 --- a/packages/core/src/plugin/default-search-plugin/types.ts +++ b/packages/core/src/plugin/default-search-plugin/types.ts @@ -40,6 +40,12 @@ export type ProductChannelMessageData = { channelId: ID; }; +export type VariantChannelMessageData = { + ctx: SerializedRequestContext; + productVariantId: ID; + channelId: ID; +}; + export class ReindexMessage extends WorkerMessage { static readonly pattern = 'Reindex'; } @@ -67,6 +73,12 @@ export class AssignProductToChannelMessage extends WorkerMessage { static readonly pattern = 'RemoveProductFromChannel'; } +export class AssignVariantToChannelMessage extends WorkerMessage { + static readonly pattern = 'AssignVariantToChannel'; +} +export class RemoveVariantFromChannelMessage extends WorkerMessage { + static readonly pattern = 'RemoveVariantFromChannel'; +} export class UpdateAssetMessage extends WorkerMessage { static readonly pattern = 'UpdateAsset'; } @@ -86,6 +98,8 @@ type UpdateAssetJobData = NamedJobData<'update-asset', UpdateAssetMessageData>; type DeleteAssetJobData = NamedJobData<'delete-asset', UpdateAssetMessageData>; type AssignProductToChannelJobData = NamedJobData<'assign-product-to-channel', ProductChannelMessageData>; type RemoveProductFromChannelJobData = NamedJobData<'remove-product-from-channel', ProductChannelMessageData>; +type AssignVariantToChannelJobData = NamedJobData<'assign-variant-to-channel', VariantChannelMessageData>; +type RemoveVariantFromChannelJobData = NamedJobData<'remove-variant-from-channel', VariantChannelMessageData>; export type UpdateIndexQueueJobData = | ReindexJobData | UpdateProductJobData @@ -96,4 +110,6 @@ export type UpdateIndexQueueJobData = | UpdateAssetJobData | DeleteAssetJobData | AssignProductToChannelJobData - | RemoveProductFromChannelJobData; + | RemoveProductFromChannelJobData + | AssignVariantToChannelJobData + | RemoveVariantFromChannelJobData; diff --git a/packages/core/src/service/services/product-variant.service.ts b/packages/core/src/service/services/product-variant.service.ts index f8edb5e3b6..6a06626928 100644 --- a/packages/core/src/service/services/product-variant.service.ts +++ b/packages/core/src/service/services/product-variant.service.ts @@ -1,26 +1,31 @@ import { Injectable } from '@nestjs/common'; import { + AssignProductVariantsToChannelInput, CreateProductVariantInput, DeletionResponse, DeletionResult, GlobalFlag, + Permission, + RemoveProductVariantsFromChannelInput, UpdateProductVariantInput, } from '@vendure/common/lib/generated-types'; import { ID, PaginatedList } from '@vendure/common/lib/shared-types'; +import { FindOptionsUtils } from 'typeorm'; import { RequestContext } from '../../api/common/request-context'; -import { InternalServerError, UserInputError } from '../../common/error/errors'; +import { ForbiddenError, InternalServerError, UserInputError } from '../../common/error/errors'; import { ListQueryOptions } from '../../common/types/common-types'; import { Translated } from '../../common/types/locale-types'; import { idsAreEqual } from '../../common/utils'; import { ConfigService } from '../../config/config.service'; -import { OrderLine, ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity'; +import { Channel, OrderLine, ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity'; import { FacetValue } from '../../entity/facet-value/facet-value.entity'; import { ProductOption } from '../../entity/product-option/product-option.entity'; import { ProductVariantTranslation } from '../../entity/product-variant/product-variant-translation.entity'; import { ProductVariant } from '../../entity/product-variant/product-variant.entity'; import { Product } from '../../entity/product/product.entity'; import { EventBus } from '../../event-bus/event-bus'; +import { ProductVariantChannelEvent } from '../../event-bus/events/product-variant-channel-event'; import { ProductVariantEvent } from '../../event-bus/events/product-variant-event'; import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder'; import { TaxCalculator } from '../helpers/tax-calculator/tax-calculator'; @@ -30,8 +35,10 @@ import { translateDeep } from '../helpers/utils/translate-entity'; import { TransactionalConnection } from '../transaction/transactional-connection'; import { AssetService } from './asset.service'; +import { ChannelService } from './channel.service'; import { FacetValueService } from './facet-value.service'; import { GlobalSettingsService } from './global-settings.service'; +import { RoleService } from './role.service'; import { StockMovementService } from './stock-movement.service'; import { TaxCategoryService } from './tax-category.service'; import { TaxRateService } from './tax-rate.service'; @@ -53,13 +60,14 @@ export class ProductVariantService { private listQueryBuilder: ListQueryBuilder, private globalSettingsService: GlobalSettingsService, private stockMovementService: StockMovementService, + private channelService: ChannelService, + private roleService: RoleService, ) {} findOne(ctx: RequestContext, productVariantId: ID): Promise | undefined> { const relations = ['product', 'product.featuredAsset', 'taxCategory']; return this.connection - .getRepository(ctx, ProductVariant) - .findOne(productVariantId, { relations }) + .findOneInChannel(ctx, ProductVariant, productVariantId, ctx.channelId, { relations }) .then(result => { if (result) { return translateDeep(this.applyChannelPriceAndTax(result, ctx), ctx.languageCode, [ @@ -71,8 +79,7 @@ export class ProductVariantService { findByIds(ctx: RequestContext, ids: ID[]): Promise>> { return this.connection - .getRepository(ctx, ProductVariant) - .findByIds(ids, { + .findByIdsInChannel(ctx, ProductVariant, ids, ctx.channelId, { relations: [ 'options', 'facetValues', @@ -94,25 +101,28 @@ export class ProductVariantService { } getVariantsByProductId(ctx: RequestContext, productId: ID): Promise>> { - return this.connection - .getRepository(ctx, ProductVariant) - .find({ - where: { - product: { id: productId } as any, - deletedAt: null, - }, - relations: [ - 'options', - 'facetValues', - 'facetValues.facet', - 'taxCategory', - 'assets', - 'featuredAsset', - ], - order: { - id: 'ASC', - }, + const qb = this.connection.getRepository(ctx, ProductVariant).createQueryBuilder('productVariant'); + const relations = [ + 'options', + 'facetValues', + 'facetValues.facet', + 'taxCategory', + 'assets', + 'featuredAsset', + ]; + FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations }); + // tslint:disable-next-line:no-non-null-assertion + FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata); + return qb + .innerJoinAndSelect('productVariant.channels', 'channel', 'channel.id = :channelId', { + channelId: ctx.channelId, + }) + .innerJoinAndSelect('productVariant.product', 'product', 'product.id = :productId', { + productId, }) + .andWhere('productVariant.deletedAt IS NULL') + .orderBy('productVariant.id', 'ASC') + .getMany() .then(variants => variants.map(variant => { const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx); @@ -132,7 +142,7 @@ export class ProductVariantService { ): Promise>> { const qb = this.listQueryBuilder .build(ProductVariant, options, { - relations: ['taxCategory'], + relations: ['taxCategory', 'channels'], channelId: ctx.channelId, ctx, }) @@ -157,6 +167,14 @@ export class ProductVariantService { }); } + async getProductVariantChannels(ctx: RequestContext, productVariantId: ID): Promise { + const variant = await this.connection.getEntityOrThrow(ctx, ProductVariant, productVariantId, { + relations: ['channels'], + channelId: ctx.channelId, + }); + return variant.channels; + } + async getVariantByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise> { const { productVariant } = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLineId, { relations: ['productVariant'], @@ -166,15 +184,17 @@ export class ProductVariantService { getOptionsForVariant(ctx: RequestContext, variantId: ID): Promise>> { return this.connection - .getRepository(ctx, ProductVariant) - .findOne(variantId, { relations: ['options'] }) + .findOneInChannel(ctx, ProductVariant, variantId, ctx.channelId, { + relations: ['options'], + }) .then(variant => (!variant ? [] : variant.options.map(o => translateDeep(o, ctx.languageCode)))); } getFacetValuesForVariant(ctx: RequestContext, variantId: ID): Promise>> { return this.connection - .getRepository(ctx, ProductVariant) - .findOne(variantId, { relations: ['facetValues', 'facetValues.facet'] }) + .findOneInChannel(ctx, ProductVariant, variantId, ctx.channelId, { + relations: ['facetValues', 'facetValues.facet'], + }) .then(variant => !variant ? [] : variant.facetValues.map(o => translateDeep(o, ctx.languageCode, ['facet'])), ); @@ -286,6 +306,7 @@ export class ProductVariantService { variant.product = { id: input.productId } as any; variant.taxCategory = { id: input.taxCategoryId } as any; await this.assetService.updateFeaturedAsset(ctx, variant, input); + this.channelService.assignToCurrentChannel(variant, ctx); }, typeOrmSubscriberData: { channelId: ctx.channelId, @@ -307,7 +328,9 @@ export class ProductVariantService { } private async updateSingle(ctx: RequestContext, input: UpdateProductVariantInput): Promise { - const existingVariant = await this.connection.getEntityOrThrow(ctx, ProductVariant, input.id); + const existingVariant = await this.connection.getEntityOrThrow(ctx, ProductVariant, input.id, { + channelId: ctx.channelId, + }); if (input.stockOnHand && input.stockOnHand < 0) { throw new UserInputError('error.stockonhand-cannot-be-negative'); } @@ -423,6 +446,86 @@ export class ProductVariantService { return variant; } + async assignProductVariantsToChannel( + ctx: RequestContext, + input: AssignProductVariantsToChannelInput, + ): Promise>> { + const hasPermission = await this.roleService.userHasPermissionOnChannel( + ctx, + input.channelId, + Permission.UpdateCatalog, + ); + if (!hasPermission) { + throw new ForbiddenError(); + } + const variants = await this.connection + .getRepository(ctx, ProductVariant) + .findByIds(input.productVariantIds); + const priceFactor = input.priceFactor != null ? input.priceFactor : 1; + for (const variant of variants) { + await this.channelService.assignToChannels(ctx, Product, variant.productId, [input.channelId]); + await this.channelService.assignToChannels(ctx, ProductVariant, variant.id, [input.channelId]); + await this.createProductVariantPrice( + ctx, + variant.id, + variant.price * priceFactor, + input.channelId, + ); + this.eventBus.publish(new ProductVariantChannelEvent(ctx, variant, input.channelId, 'assigned')); + } + return this.findByIds( + ctx, + variants.map(v => v.id), + ); + } + + async removeProductVariantsFromChannel( + ctx: RequestContext, + input: RemoveProductVariantsFromChannelInput, + ): Promise>> { + const hasPermission = await this.roleService.userHasPermissionOnChannel( + ctx, + input.channelId, + Permission.UpdateCatalog, + ); + if (!hasPermission) { + throw new ForbiddenError(); + } + if (idsAreEqual(input.channelId, this.channelService.getDefaultChannel().id)) { + throw new UserInputError('error.products-cannot-be-removed-from-default-channel'); + } + const variants = await this.connection + .getRepository(ctx, ProductVariant) + .findByIds(input.productVariantIds); + for (const variant of variants) { + await this.channelService.removeFromChannels(ctx, ProductVariant, variant.id, [input.channelId]); + await this.connection.getRepository(ctx, ProductVariantPrice).delete({ + channelId: input.channelId, + variant, + }); + // If none of the ProductVariants is assigned to the Channel, remove the Channel from Product + const productVariants = await this.connection.getRepository(ctx, ProductVariant).find({ + where: { + productId: variant.productId, + }, + relations: ['channels'], + }); + const productChannelsFromVariants = ([] as Channel[]).concat( + ...productVariants.map(pv => pv.channels), + ); + if (!productChannelsFromVariants.find(c => c.id === input.channelId)) { + await this.channelService.removeFromChannels(ctx, Product, variant.productId, [ + input.channelId, + ]); + } + this.eventBus.publish(new ProductVariantChannelEvent(ctx, variant, input.channelId, 'removed')); + } + return this.findByIds( + ctx, + variants.map(v => v.id), + ); + } + private async validateVariantOptionIds(ctx: RequestContext, input: CreateProductVariantInput) { // this could be done with less queries but depending on the data, node will crash // https://github.com/vendure-ecommerce/vendure/issues/328 diff --git a/packages/core/src/service/services/product.service.ts b/packages/core/src/service/services/product.service.ts index 6243b62930..3a341fb326 100644 --- a/packages/core/src/service/services/product.service.ts +++ b/packages/core/src/service/services/product.service.ts @@ -4,7 +4,6 @@ import { CreateProductInput, DeletionResponse, DeletionResult, - Permission, RemoveOptionGroupFromProductResult, RemoveProductsFromChannelInput, UpdateProductInput, @@ -14,7 +13,7 @@ import { FindOptionsUtils } from 'typeorm'; import { RequestContext } from '../../api/common/request-context'; import { ErrorResultUnion } from '../../common/error/error-result'; -import { EntityNotFoundError, ForbiddenError, UserInputError } from '../../common/error/errors'; +import { EntityNotFoundError } from '../../common/error/errors'; import { ProductOptionInUseError } from '../../common/error/generated-graphql-admin-errors'; import { ListQueryOptions } from '../../common/types/common-types'; import { Translated } from '../../common/types/locale-types'; @@ -103,6 +102,7 @@ export class ProductService { FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata); return qb .leftJoin('product.channels', 'channel') + .andWhere('product.deletedAt IS NULL') .andWhere('product.id IN (:...ids)', { ids: productIds }) .andWhere('channel.id = :channelId', { channelId: ctx.channelId }) .getMany() @@ -200,30 +200,20 @@ export class ProductService { ctx: RequestContext, input: AssignProductsToChannelInput, ): Promise>> { - const hasPermission = await this.roleService.userHasPermissionOnChannel( - ctx, - input.channelId, - Permission.UpdateCatalog, - ); - if (!hasPermission) { - throw new ForbiddenError(); - } const productsWithVariants = await this.connection .getRepository(ctx, Product) .findByIds(input.productIds, { relations: ['variants'], }); - const priceFactor = input.priceFactor != null ? input.priceFactor : 1; - for (const product of productsWithVariants) { - await this.channelService.assignToChannels(ctx, Product, product.id, [input.channelId]); - for (const variant of product.variants) { - await this.productVariantService.createProductVariantPrice( - ctx, - variant.id, - variant.price * priceFactor, - input.channelId, - ); - } + await this.productVariantService.assignProductVariantsToChannel(ctx, { + productVariantIds: ([] as ID[]).concat( + ...productsWithVariants.map(p => p.variants.map(v => v.id)), + ), + channelId: input.channelId, + priceFactor: input.priceFactor, + }); + const products = await this.connection.getRepository(ctx, Product).findByIds(input.productIds); + for (const product of products) { this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'assigned')); } return this.findByIds( @@ -236,25 +226,24 @@ export class ProductService { ctx: RequestContext, input: RemoveProductsFromChannelInput, ): Promise>> { - const hasPermission = await this.roleService.userHasPermissionOnChannel( - ctx, - input.channelId, - Permission.UpdateCatalog, - ); - if (!hasPermission) { - throw new ForbiddenError(); - } - if (idsAreEqual(input.channelId, this.channelService.getDefaultChannel().id)) { - throw new UserInputError('error.products-cannot-be-removed-from-default-channel'); - } + const productsWithVariants = await this.connection + .getRepository(ctx, Product) + .findByIds(input.productIds, { + relations: ['variants'], + }); + await this.productVariantService.removeProductVariantsFromChannel(ctx, { + productVariantIds: ([] as ID[]).concat( + ...productsWithVariants.map(p => p.variants.map(v => v.id)), + ), + channelId: input.channelId, + }); const products = await this.connection.getRepository(ctx, Product).findByIds(input.productIds); for (const product of products) { - await this.channelService.removeFromChannels(ctx, Product, product.id, [input.channelId]); this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'removed')); } return this.findByIds( ctx, - products.map(p => p.id), + productsWithVariants.map(p => p.id), ); } diff --git a/packages/core/src/service/transaction/transactional-connection.ts b/packages/core/src/service/transaction/transactional-connection.ts index d783276e5f..f946bf827b 100644 --- a/packages/core/src/service/transaction/transactional-connection.ts +++ b/packages/core/src/service/transaction/transactional-connection.ts @@ -190,7 +190,7 @@ export class TransactionalConnection { const qb = this.getRepository(ctx, entity).createQueryBuilder('entity'); FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, options); - if (options.loadEagerRelations) { + if (options.loadEagerRelations !== false) { // tslint:disable-next-line:no-non-null-assertion FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata); } diff --git a/packages/dev-server/dev-config.ts b/packages/dev-server/dev-config.ts index ae612a2268..afe958e21a 100644 --- a/packages/dev-server/dev-config.ts +++ b/packages/dev-server/dev-config.ts @@ -11,6 +11,7 @@ import { PermissionDefinition, VendureConfig, } from '@vendure/core'; +import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin'; import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin'; import path from 'path'; import { ConnectionOptions } from 'typeorm'; @@ -63,12 +64,12 @@ export const devConfig: VendureConfig = { assetUploadDir: path.join(__dirname, 'assets'), port: 5002, }), - DefaultSearchPlugin, + // DefaultSearchPlugin, DefaultJobQueuePlugin, - /*ElasticsearchPlugin.init({ + ElasticsearchPlugin.init({ host: 'http://localhost', port: 9200, - }),*/ + }), EmailPlugin.init({ devMode: true, handlers: defaultEmailHandlers, diff --git a/packages/elasticsearch-plugin/e2e/e2e-helpers.ts b/packages/elasticsearch-plugin/e2e/e2e-helpers.ts new file mode 100644 index 0000000000..d54e997502 --- /dev/null +++ b/packages/elasticsearch-plugin/e2e/e2e-helpers.ts @@ -0,0 +1,192 @@ +import { Client } from '@elastic/elasticsearch'; +import { SortOrder } from '@vendure/common/lib/generated-types'; +import { SimpleGraphQLClient } from '@vendure/testing'; + +import { SearchGetPrices, SearchInput } from '../../core/e2e/graphql/generated-e2e-admin-types'; +import { LogicalOperator, SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types'; +import { SEARCH_PRODUCTS_SHOP } from '../../core/e2e/graphql/shop-definitions'; +import { deleteIndices } from '../src/indexing-utils'; + +import { SEARCH_GET_PRICES, SEARCH_PRODUCTS } from './elasticsearch-plugin.e2e-spec'; +import { SearchProductsAdmin } from './graphql/generated-e2e-elasticsearch-plugin-types'; + +// tslint:disable-next-line:no-var-requires +const { elasticsearchHost, elasticsearchPort } = require('./constants'); + +export function doAdminSearchQuery(client: SimpleGraphQLClient, input: SearchInput) { + return client.query(SEARCH_PRODUCTS, { + input, + }); +} + +export async function testGroupByProduct(client: SimpleGraphQLClient) { + const result = await client.query( + SEARCH_PRODUCTS_SHOP, + { + input: { + groupByProduct: true, + }, + }, + ); + expect(result.search.totalItems).toBe(20); +} + +export async function testNoGrouping(client: SimpleGraphQLClient) { + const result = await client.query( + SEARCH_PRODUCTS_SHOP, + { + input: { + groupByProduct: false, + }, + }, + ); + expect(result.search.totalItems).toBe(34); +} + +export async function testMatchSearchTerm(client: SimpleGraphQLClient) { + const result = await client.query( + SEARCH_PRODUCTS_SHOP, + { + input: { + term: 'camera', + groupByProduct: true, + }, + }, + ); + expect(result.search.items.map(i => i.productName)).toEqual([ + 'Instant Camera', + 'Camera Lens', + 'SLR Camera', + ]); +} + +export async function testMatchFacetIdsAnd(client: SimpleGraphQLClient) { + const result = await client.query( + SEARCH_PRODUCTS_SHOP, + { + input: { + facetValueIds: ['T_1', 'T_2'], + facetValueOperator: LogicalOperator.AND, + groupByProduct: true, + sort: { + name: SortOrder.ASC, + }, + }, + }, + ); + expect(result.search.items.map(i => i.productName)).toEqual([ + 'Clacky Keyboard', + 'Curvy Monitor', + 'Gaming PC', + 'Hard Drive', + 'Laptop', + 'USB Cable', + ]); +} + +export async function testMatchFacetIdsOr(client: SimpleGraphQLClient) { + const result = await client.query( + SEARCH_PRODUCTS_SHOP, + { + input: { + facetValueIds: ['T_1', 'T_5'], + facetValueOperator: LogicalOperator.OR, + groupByProduct: true, + sort: { + name: SortOrder.ASC, + }, + take: 20, + }, + }, + ); + expect(result.search.items.map(i => i.productName)).toEqual([ + 'Bonsai Tree', + 'Camera Lens', + 'Clacky Keyboard', + 'Curvy Monitor', + 'Gaming PC', + 'Hard Drive', + 'Instant Camera', + 'Laptop', + 'Orchid', + 'SLR Camera', + 'Spiky Cactus', + 'Tripod', + 'USB Cable', + ]); +} + +export async function testMatchCollectionId(client: SimpleGraphQLClient) { + const result = await client.query( + SEARCH_PRODUCTS_SHOP, + { + input: { + collectionId: 'T_2', + groupByProduct: true, + }, + }, + ); + expect(result.search.items.map(i => i.productName)).toEqual(['Spiky Cactus', 'Orchid', 'Bonsai Tree']); +} + +export async function testMatchCollectionSlug(client: SimpleGraphQLClient) { + const result = await client.query( + SEARCH_PRODUCTS_SHOP, + { + input: { + collectionSlug: 'plants', + groupByProduct: true, + }, + }, + ); + expect(result.search.items.map(i => i.productName)).toEqual(['Spiky Cactus', 'Orchid', 'Bonsai Tree']); +} + +export async function testSinglePrices(client: SimpleGraphQLClient) { + const result = await client.query(SEARCH_GET_PRICES, { + input: { + groupByProduct: false, + take: 3, + sort: { + price: SortOrder.ASC, + }, + }, + }); + expect(result.search.items).toEqual([ + { + price: { value: 799 }, + priceWithTax: { value: 959 }, + }, + { + price: { value: 1498 }, + priceWithTax: { value: 1798 }, + }, + { + price: { value: 1550 }, + priceWithTax: { value: 1860 }, + }, + ]); +} + +export async function testPriceRanges(client: SimpleGraphQLClient) { + const result = await client.query(SEARCH_GET_PRICES, { + input: { + groupByProduct: true, + take: 3, + term: 'laptop', + }, + }); + expect(result.search.items).toEqual([ + { + price: { min: 129900, max: 229900 }, + priceWithTax: { min: 155880, max: 275880 }, + }, + ]); +} + +export async function dropElasticIndices(indexPrefix: string) { + const esClient = new Client({ + node: `${elasticsearchHost}:${elasticsearchPort}`, + }); + return deleteIndices(esClient, indexPrefix); +} diff --git a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts b/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts index 81496cfbb9..e4e88ee5e7 100644 --- a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts +++ b/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts @@ -18,6 +18,7 @@ import { initialData } from '../../../e2e-common/e2e-initial-data'; import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; import { AssignProductsToChannel, + AssignProductVariantsToChannel, ChannelFragment, CreateChannel, CreateCollection, @@ -28,6 +29,7 @@ import { DeleteProductVariant, LanguageCode, RemoveProductsFromChannel, + RemoveProductVariantsFromChannel, SearchFacetValues, SearchGetPrices, SearchInput, @@ -39,6 +41,7 @@ import { } from '../../core/e2e/graphql/generated-e2e-admin-types'; import { LogicalOperator, SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types'; import { + ASSIGN_PRODUCTVARIANT_TO_CHANNEL, ASSIGN_PRODUCT_TO_CHANNEL, CREATE_CHANNEL, CREATE_COLLECTION, @@ -46,6 +49,7 @@ import { DELETE_ASSET, DELETE_PRODUCT, DELETE_PRODUCT_VARIANT, + REMOVE_PRODUCTVARIANT_FROM_CHANNEL, REMOVE_PRODUCT_FROM_CHANNEL, UPDATE_ASSET, UPDATE_COLLECTION, @@ -58,8 +62,19 @@ import { awaitRunningJobs } from '../../core/e2e/utils/await-running-jobs'; import { loggerCtx } from '../src/constants'; import { ElasticsearchPlugin } from '../src/plugin'; -// tslint:disable-next-line:no-var-requires -const { elasticsearchHost, elasticsearchPort } = require('./constants'); +import { + doAdminSearchQuery, + dropElasticIndices, + testGroupByProduct, + testMatchCollectionId, + testMatchCollectionSlug, + testMatchFacetIdsAnd, + testMatchFacetIdsOr, + testMatchSearchTerm, + testNoGrouping, + testPriceRanges, + testSinglePrices, +} from './e2e-helpers'; import { GetJobInfo, JobState, @@ -67,6 +82,9 @@ import { SearchProductsAdmin, } from './graphql/generated-e2e-elasticsearch-plugin-types'; +// tslint:disable-next-line:no-var-requires +const { elasticsearchHost, elasticsearchPort } = require('./constants'); + /** * The Elasticsearch tests sometimes take a long time in CI due to limited resources. * We increase the timeout to 30 seconds to prevent failure due to timeouts. @@ -75,6 +93,8 @@ if (process.env.CI) { jest.setTimeout(10 * 3000); } +const INDEX_PREFIX = 'e2e-tests'; + describe('Elasticsearch plugin', () => { const { server, adminClient, shopClient } = createTestEnvironment( mergeConfig(testConfig, { @@ -89,7 +109,7 @@ describe('Elasticsearch plugin', () => { logger: new DefaultLogger({ level: LogLevel.Info }), plugins: [ ElasticsearchPlugin.init({ - indexPrefix: 'e2e-tests', + indexPrefix: INDEX_PREFIX, port: elasticsearchPort, host: elasticsearchHost, }), @@ -99,6 +119,7 @@ describe('Elasticsearch plugin', () => { ); beforeAll(async () => { + await dropElasticIndices(INDEX_PREFIX); await server.init({ initialData, productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'), @@ -113,191 +134,6 @@ describe('Elasticsearch plugin', () => { await server.destroy(); }); - function doAdminSearchQuery(input: SearchInput) { - return adminClient.query(SEARCH_PRODUCTS, { - input, - }); - } - - async function testGroupByProduct(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_PRODUCTS_SHOP, - { - input: { - groupByProduct: true, - }, - }, - ); - expect(result.search.totalItems).toBe(20); - } - - async function testNoGrouping(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_PRODUCTS_SHOP, - { - input: { - groupByProduct: false, - }, - }, - ); - expect(result.search.totalItems).toBe(34); - } - - async function testMatchSearchTerm(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_PRODUCTS_SHOP, - { - input: { - term: 'camera', - groupByProduct: true, - }, - }, - ); - expect(result.search.items.map(i => i.productName)).toEqual([ - 'Instant Camera', - 'Camera Lens', - 'SLR Camera', - ]); - } - - async function testMatchFacetIdsAnd(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_PRODUCTS_SHOP, - { - input: { - facetValueIds: ['T_1', 'T_2'], - facetValueOperator: LogicalOperator.AND, - groupByProduct: true, - sort: { - name: SortOrder.ASC, - }, - }, - }, - ); - expect(result.search.items.map(i => i.productName)).toEqual([ - 'Clacky Keyboard', - 'Curvy Monitor', - 'Gaming PC', - 'Hard Drive', - 'Laptop', - 'USB Cable', - ]); - } - - async function testMatchFacetIdsOr(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_PRODUCTS_SHOP, - { - input: { - facetValueIds: ['T_1', 'T_5'], - facetValueOperator: LogicalOperator.OR, - groupByProduct: true, - sort: { - name: SortOrder.ASC, - }, - take: 20, - }, - }, - ); - expect(result.search.items.map(i => i.productName)).toEqual([ - 'Bonsai Tree', - 'Camera Lens', - 'Clacky Keyboard', - 'Curvy Monitor', - 'Gaming PC', - 'Hard Drive', - 'Instant Camera', - 'Laptop', - 'Orchid', - 'SLR Camera', - 'Spiky Cactus', - 'Tripod', - 'USB Cable', - ]); - } - - async function testMatchCollectionId(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_PRODUCTS_SHOP, - { - input: { - collectionId: 'T_2', - groupByProduct: true, - }, - }, - ); - expect(result.search.items.map(i => i.productName)).toEqual([ - 'Spiky Cactus', - 'Orchid', - 'Bonsai Tree', - ]); - } - - async function testMatchCollectionSlug(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_PRODUCTS_SHOP, - { - input: { - collectionSlug: 'plants', - groupByProduct: true, - }, - }, - ); - expect(result.search.items.map(i => i.productName)).toEqual([ - 'Spiky Cactus', - 'Orchid', - 'Bonsai Tree', - ]); - } - - async function testSinglePrices(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_GET_PRICES, - { - input: { - groupByProduct: false, - take: 3, - sort: { - price: SortOrder.ASC, - }, - }, - }, - ); - expect(result.search.items).toEqual([ - { - price: { value: 799 }, - priceWithTax: { value: 959 }, - }, - { - price: { value: 1498 }, - priceWithTax: { value: 1798 }, - }, - { - price: { value: 1550 }, - priceWithTax: { value: 1860 }, - }, - ]); - } - - async function testPriceRanges(client: SimpleGraphQLClient) { - const result = await client.query( - SEARCH_GET_PRICES, - { - input: { - groupByProduct: true, - take: 3, - term: 'laptop', - }, - }, - ); - expect(result.search.items).toEqual([ - { - price: { min: 129900, max: 229900 }, - priceWithTax: { min: 155880, max: 275880 }, - }, - ]); - } - describe('shop api', () => { it('group by product', () => testGroupByProduct(shopClient)); @@ -474,7 +310,10 @@ describe('Elasticsearch plugin', () => { describe('updating the index', () => { it('updates index when ProductVariants are changed', async () => { await awaitRunningJobs(adminClient); - const { search } = await doAdminSearchQuery({ term: 'drive', groupByProduct: false }); + const { search } = await doAdminSearchQuery(adminClient, { + term: 'drive', + groupByProduct: false, + }); expect(search.items.map(i => i.sku)).toEqual([ 'IHD455T1', 'IHD455T2', @@ -494,7 +333,7 @@ describe('Elasticsearch plugin', () => { ); await awaitRunningJobs(adminClient); - const { search: search2 } = await doAdminSearchQuery({ + const { search: search2 } = await doAdminSearchQuery(adminClient, { term: 'drive', groupByProduct: false, }); @@ -510,7 +349,10 @@ describe('Elasticsearch plugin', () => { it('updates index when ProductVariants are deleted', async () => { await awaitRunningJobs(adminClient); - const { search } = await doAdminSearchQuery({ term: 'drive', groupByProduct: false }); + const { search } = await doAdminSearchQuery(adminClient, { + term: 'drive', + groupByProduct: false, + }); await adminClient.query( DELETE_PRODUCT_VARIANT, @@ -520,7 +362,7 @@ describe('Elasticsearch plugin', () => { ); await awaitRunningJobs(adminClient); - const { search: search2 } = await doAdminSearchQuery({ + const { search: search2 } = await doAdminSearchQuery(adminClient, { term: 'drive', groupByProduct: false, }); @@ -541,7 +383,10 @@ describe('Elasticsearch plugin', () => { }, }); await awaitRunningJobs(adminClient); - const result = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true }); + const result = await doAdminSearchQuery(adminClient, { + facetValueIds: ['T_2'], + groupByProduct: true, + }); expect(result.search.items.map(i => i.productName).sort()).toEqual([ 'Clacky Keyboard', 'Curvy Monitor', @@ -552,7 +397,10 @@ describe('Elasticsearch plugin', () => { }); it('updates index when a Product is deleted', async () => { - const { search } = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true }); + const { search } = await doAdminSearchQuery(adminClient, { + facetValueIds: ['T_2'], + groupByProduct: true, + }); expect(search.items.map(i => i.productId).sort()).toEqual([ 'T_2', 'T_3', @@ -564,7 +412,7 @@ describe('Elasticsearch plugin', () => { id: 'T_5', }); await awaitRunningJobs(adminClient); - const { search: search2 } = await doAdminSearchQuery({ + const { search: search2 } = await doAdminSearchQuery(adminClient, { facetValueIds: ['T_2'], groupByProduct: true, }); @@ -598,7 +446,10 @@ describe('Elasticsearch plugin', () => { await awaitRunningJobs(adminClient); // add an additional check for the collection filters to update await awaitRunningJobs(adminClient); - const result1 = await doAdminSearchQuery({ collectionId: 'T_2', groupByProduct: true }); + const result1 = await doAdminSearchQuery(adminClient, { + collectionId: 'T_2', + groupByProduct: true, + }); expect(result1.search.items.map(i => i.productName)).toEqual([ 'Road Bike', @@ -610,7 +461,10 @@ describe('Elasticsearch plugin', () => { 'Running Shoe', ]); - const result2 = await doAdminSearchQuery({ collectionSlug: 'plants', groupByProduct: true }); + const result2 = await doAdminSearchQuery(adminClient, { + collectionSlug: 'plants', + groupByProduct: true, + }); expect(result2.search.items.map(i => i.productName)).toEqual([ 'Road Bike', @@ -657,7 +511,7 @@ describe('Elasticsearch plugin', () => { await awaitRunningJobs(adminClient); // add an additional check for the collection filters to update await awaitRunningJobs(adminClient); - const result = await doAdminSearchQuery({ + const result = await doAdminSearchQuery(adminClient, { collectionId: createCollection.id, groupByProduct: true, }); @@ -698,7 +552,7 @@ describe('Elasticsearch plugin', () => { describe('asset changes', () => { function searchForLaptop() { - return doAdminSearchQuery({ + return doAdminSearchQuery(adminClient, { term: 'laptop', groupByProduct: true, take: 1, @@ -752,7 +606,7 @@ describe('Elasticsearch plugin', () => { }); it('does not include deleted ProductVariants in index', async () => { - const { search: s1 } = await doAdminSearchQuery({ + const { search: s1 } = await doAdminSearchQuery(adminClient, { term: 'hard drive', groupByProduct: false, }); @@ -777,7 +631,10 @@ describe('Elasticsearch plugin', () => { }); it('returns disabled field when not grouped', async () => { - const result = await doAdminSearchQuery({ groupByProduct: false, term: 'laptop' }); + const result = await doAdminSearchQuery(adminClient, { + groupByProduct: false, + term: 'laptop', + }); expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([ { productVariantId: 'T_1', enabled: true }, { productVariantId: 'T_2', enabled: true }, @@ -797,7 +654,10 @@ describe('Elasticsearch plugin', () => { }, ); await awaitRunningJobs(adminClient); - const result = await doAdminSearchQuery({ groupByProduct: true, term: 'laptop' }); + const result = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + term: 'laptop', + }); expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([ { productId: 'T_1', enabled: true }, ]); @@ -811,7 +671,11 @@ describe('Elasticsearch plugin', () => { }, ); await awaitRunningJobs(adminClient); - const result = await doAdminSearchQuery({ groupByProduct: true, take: 3, term: 'laptop' }); + const result = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + take: 3, + term: 'laptop', + }); expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([ { productId: 'T_1', enabled: false }, ]); @@ -825,7 +689,10 @@ describe('Elasticsearch plugin', () => { }, }); await awaitRunningJobs(adminClient); - const result = await doAdminSearchQuery({ groupByProduct: true, term: 'gaming' }); + const result = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + term: 'gaming', + }); expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([ { productId: 'T_3', enabled: false }, ]); @@ -836,7 +703,7 @@ describe('Elasticsearch plugin', () => { await adminClient.query(REINDEX); await awaitRunningJobs(adminClient); - const result = await doAdminSearchQuery({ groupByProduct: true, take: 3 }); + const result = await doAdminSearchQuery(adminClient, { groupByProduct: true, take: 3 }); expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([ { productId: 'T_1', enabled: false }, { productId: 'T_2', enabled: true }, @@ -865,6 +732,21 @@ describe('Elasticsearch plugin', () => { }, }); secondChannel = createChannel as ChannelFragment; + + adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + await adminClient.query(REINDEX); + await awaitRunningJobs(adminClient); + }); + + it('new channel is initially empty', async () => { + const { search: searchGrouped } = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + }); + const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, { + groupByProduct: false, + }); + expect(searchGrouped.totalItems).toEqual(0); + expect(searchUngrouped.totalItems).toEqual(0); }); it('adding product to channel', async () => { @@ -878,7 +760,7 @@ describe('Elasticsearch plugin', () => { await awaitRunningJobs(adminClient); adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); - const { search } = await doAdminSearchQuery({ groupByProduct: true }); + const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true }); expect(search.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_2']); }); @@ -896,7 +778,7 @@ describe('Elasticsearch plugin', () => { await awaitRunningJobs(adminClient); adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); - const { search } = await doAdminSearchQuery({ groupByProduct: true }); + const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true }); expect(search.items.map(i => i.productId)).toEqual(['T_1']); }); @@ -912,9 +794,67 @@ describe('Elasticsearch plugin', () => { ); expect(job!.state).toBe(JobState.COMPLETED); - const { search } = await doAdminSearchQuery({ groupByProduct: true }); + const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true }); expect(search.items.map(i => i.productId).sort()).toEqual(['T_1']); }); + + it('adding product variant to channel', async () => { + adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + await adminClient.query< + AssignProductVariantsToChannel.Mutation, + AssignProductVariantsToChannel.Variables + >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, { + input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] }, + }); + await awaitRunningJobs(adminClient); + + adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + + const { search: searchGrouped } = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + }); + expect(searchGrouped.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_3', 'T_4']); + + const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, { + groupByProduct: false, + }); + expect(searchUngrouped.items.map(i => i.productVariantId).sort()).toEqual([ + 'T_1', + 'T_10', + 'T_15', + 'T_2', + 'T_3', + 'T_4', + ]); + }); + + it('removing product variant from channel', async () => { + adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + await adminClient.query< + RemoveProductVariantsFromChannel.Mutation, + RemoveProductVariantsFromChannel.Variables + >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, { + input: { channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15'] }, + }); + await awaitRunningJobs(adminClient); + + adminClient.setChannelToken(SECOND_CHANNEL_TOKEN); + + const { search: searchGrouped } = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + }); + expect(searchGrouped.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_3']); + + const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, { + groupByProduct: false, + }); + expect(searchUngrouped.items.map(i => i.productVariantId).sort()).toEqual([ + 'T_10', + 'T_2', + 'T_3', + 'T_4', + ]); + }); }); describe('multiple language handling', () => { @@ -935,6 +875,7 @@ describe('Elasticsearch plugin', () => { } beforeAll(async () => { + adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); const { updateProduct } = await adminClient.query< UpdateProduct.Mutation, UpdateProduct.Variables diff --git a/packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts b/packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts index 8c5e5c1f99..ec28485937 100644 --- a/packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts +++ b/packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts @@ -362,10 +362,14 @@ export type Mutation = { updateProductVariants: Array>; /** Delete a ProductVariant */ deleteProductVariant: DeletionResponse; - /** Assigns Products to the specified Channel */ + /** Assigns all ProductVariants of Product to the specified Channel */ assignProductsToChannel: Array; - /** Removes Products from the specified Channel */ + /** Removes all ProductVariants of Product from the specified Channel */ removeProductsFromChannel: Array; + /** Assigns ProductVariants to the specified Channel */ + assignProductVariantsToChannel: Array; + /** Removes ProductVariants from the specified Channel */ + removeProductVariantsFromChannel: Array; createPromotion: CreatePromotionResult; updatePromotion: UpdatePromotionResult; deletePromotion: DeletionResponse; @@ -702,6 +706,14 @@ export type MutationRemoveProductsFromChannelArgs = { input: RemoveProductsFromChannelInput; }; +export type MutationAssignProductVariantsToChannelArgs = { + input: AssignProductVariantsToChannelInput; +}; + +export type MutationRemoveProductVariantsFromChannelArgs = { + input: RemoveProductVariantsFromChannelInput; +}; + export type MutationCreatePromotionArgs = { input: CreatePromotionInput; }; @@ -1503,6 +1515,7 @@ export type ProductVariant = Node & { outOfStockThreshold: Scalars['Int']; useGlobalOutOfStockThreshold: Scalars['Boolean']; stockMovements: StockMovementList; + channels: Array; id: Scalars['ID']; product: Product; productId: Scalars['ID']; @@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = { channelId: Scalars['ID']; }; +export type AssignProductVariantsToChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; + priceFactor?: Maybe; +}; + +export type RemoveProductVariantsFromChannelInput = { + productVariantIds: Array; + channelId: Scalars['ID']; +}; + export type ProductOptionInUseError = ErrorResult & { errorCode: ErrorCode; message: Scalars['String']; diff --git a/packages/elasticsearch-plugin/src/elasticsearch-index.service.ts b/packages/elasticsearch-plugin/src/elasticsearch-index.service.ts index a476e9b1b5..3c7fd1379c 100644 --- a/packages/elasticsearch-plugin/src/elasticsearch-index.service.ts +++ b/packages/elasticsearch-plugin/src/elasticsearch-index.service.ts @@ -17,11 +17,13 @@ import { import { ReindexMessageResponse } from './indexer.controller'; import { AssignProductToChannelMessage, + AssignVariantToChannelMessage, DeleteAssetMessage, DeleteProductMessage, DeleteVariantMessage, ReindexMessage, RemoveProductFromChannelMessage, + RemoveVariantFromChannelMessage, UpdateAssetMessage, UpdateIndexQueueJobData, UpdateProductMessage, @@ -39,7 +41,7 @@ export class ElasticsearchIndexService { updateIndexQueue = this.jobService.createQueue({ name: 'update-search-index', concurrency: 1, - process: (job) => { + process: job => { const data = job.data; switch (data.type) { case 'reindex': @@ -73,6 +75,12 @@ export class ElasticsearchIndexService { case 'remove-product-from-channel': this.sendMessage(job, new RemoveProductFromChannelMessage(data)); break; + case 'assign-variant-to-channel': + this.sendMessage(job, new AssignVariantToChannelMessage(data)); + break; + case 'remove-variant-from-channel': + this.sendMessage(job, new RemoveVariantFromChannelMessage(data)); + break; default: assertNever(data); } @@ -89,7 +97,7 @@ export class ElasticsearchIndexService { } updateVariants(ctx: RequestContext, variants: ProductVariant[]) { - const variantIds = variants.map((v) => v.id); + const variantIds = variants.map(v => v.id); this.addJobToQueue({ type: 'update-variants', ctx: ctx.serialize(), variantIds }); } @@ -98,7 +106,7 @@ export class ElasticsearchIndexService { } deleteVariant(ctx: RequestContext, variants: ProductVariant[]) { - const variantIds = variants.map((v) => v.id); + const variantIds = variants.map(v => v.id); this.addJobToQueue({ type: 'delete-variant', ctx: ctx.serialize(), variantIds }); } @@ -120,6 +128,24 @@ export class ElasticsearchIndexService { }); } + assignVariantToChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) { + this.addJobToQueue({ + type: 'assign-variant-to-channel', + ctx: ctx.serialize(), + productVariantId, + channelId, + }); + } + + removeVariantFromChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) { + this.addJobToQueue({ + type: 'remove-variant-from-channel', + ctx: ctx.serialize(), + productVariantId, + channelId, + }); + } + updateVariantsById(ctx: RequestContext, ids: ID[]) { this.addJobToQueue({ type: 'update-variants-by-id', ctx: ctx.serialize(), ids }); } @@ -141,7 +167,7 @@ export class ElasticsearchIndexService { private sendMessage(job: Job, message: WorkerMessage) { this.workerService.send(message).subscribe({ complete: () => job.complete(true), - error: (err) => { + error: err => { Logger.error(err); job.fail(err); }, @@ -159,7 +185,7 @@ export class ElasticsearchIndexService { } duration = response.duration; completed = response.completed; - const progress = Math.ceil((completed / total) * 100); + const progress = total === 0 ? 100 : Math.ceil((completed / total) * 100); job.setProgress(progress); }, complete: () => { diff --git a/packages/elasticsearch-plugin/src/indexer.controller.ts b/packages/elasticsearch-plugin/src/indexer.controller.ts index 0ea65cec5b..879937c1f7 100644 --- a/packages/elasticsearch-plugin/src/indexer.controller.ts +++ b/packages/elasticsearch-plugin/src/indexer.controller.ts @@ -9,6 +9,7 @@ import { ConfigService, FacetValue, ID, + idsAreEqual, LanguageCode, Logger, Product, @@ -29,6 +30,7 @@ import { createIndices, deleteByChannel, deleteIndices } from './indexing-utils' import { ElasticsearchOptions } from './options'; import { AssignProductToChannelMessage, + AssignVariantToChannelMessage, BulkOperation, BulkOperationDoc, BulkResponseBody, @@ -38,6 +40,7 @@ import { ProductIndexItem, ReindexMessage, RemoveProductFromChannelMessage, + RemoveVariantFromChannelMessage, UpdateAssetMessage, UpdateProductMessage, UpdateVariantMessage, @@ -56,6 +59,7 @@ export const variantRelations = [ 'facetValues.facet', 'collections', 'taxCategory', + 'channels', ]; export interface ReindexMessageResponse { @@ -167,6 +171,42 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes }); } + @MessagePattern(AssignVariantToChannelMessage.pattern) + assignVariantToChannel({ + ctx: rawContext, + productVariantId, + channelId, + }: AssignVariantToChannelMessage['data']): Observable { + const ctx = RequestContext.deserialize(rawContext); + return asyncObservable(async () => { + await this.updateVariantsInternal(ctx, [productVariantId], channelId); + return true; + }); + } + + @MessagePattern(RemoveVariantFromChannelMessage.pattern) + removeVariantFromChannel({ + ctx: rawContext, + productVariantId, + channelId, + }: AssignVariantToChannelMessage['data']): Observable { + const ctx = RequestContext.deserialize(rawContext); + return asyncObservable(async () => { + const productVariant = await this.connection.getEntityOrThrow( + ctx, + ProductVariant, + productVariantId, + { relations: ['product', 'product.channels'] }, + ); + await this.deleteVariantsInternal([productVariant], channelId); + + if (!productVariant.product.channels.find(c => idsAreEqual(c.id, channelId))) { + await this.deleteProductInternal(productVariant.product, channelId); + } + return true; + }); + } + /** * Updates the search index only for the affected entities. */ @@ -724,7 +764,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes currencyCode: v.currencyCode, description: productTranslation.description, facetIds: this.getFacetIds([v]), - channelIds: v.product.channels.map(c => c.id), + channelIds: v.channels.map(c => c.id), facetValueIds: this.getFacetValueIds([v]), collectionIds: v.collections.map(c => c.id.toString()), collectionSlugs: v.collections.map(c => c.slug), diff --git a/packages/elasticsearch-plugin/src/plugin.ts b/packages/elasticsearch-plugin/src/plugin.ts index 0230c52bba..2fa95cf882 100644 --- a/packages/elasticsearch-plugin/src/plugin.ts +++ b/packages/elasticsearch-plugin/src/plugin.ts @@ -11,6 +11,7 @@ import { PluginCommonModule, ProductChannelEvent, ProductEvent, + ProductVariantChannelEvent, ProductVariantEvent, TaxRateModificationEvent, Type, @@ -295,6 +296,22 @@ export class ElasticsearchPlugin implements OnVendureBootstrap { } }); + this.eventBus.ofType(ProductVariantChannelEvent).subscribe(event => { + if (event.type === 'assigned') { + return this.elasticsearchIndexService.assignVariantToChannel( + event.ctx, + event.productVariant.id, + event.channelId, + ); + } else { + return this.elasticsearchIndexService.removeVariantFromChannel( + event.ctx, + event.productVariant.id, + event.channelId, + ); + } + }); + const collectionModification$ = this.eventBus.ofType(CollectionModificationEvent); const closingNotifier$ = collectionModification$.pipe(debounceTime(50)); collectionModification$ diff --git a/packages/elasticsearch-plugin/src/types.ts b/packages/elasticsearch-plugin/src/types.ts index f7fc986d64..6643a88882 100644 --- a/packages/elasticsearch-plugin/src/types.ts +++ b/packages/elasticsearch-plugin/src/types.ts @@ -178,6 +178,13 @@ export interface ProductChannelMessageData { productId: ID; channelId: ID; } + +export type VariantChannelMessageData = { + ctx: SerializedRequestContext; + productVariantId: ID; + channelId: ID; +}; + export interface UpdateAssetMessageData { ctx: SerializedRequestContext; asset: JsonCompatible>; @@ -210,6 +217,12 @@ export class AssignProductToChannelMessage extends WorkerMessage { static readonly pattern = 'RemoveProductFromChannel'; } +export class AssignVariantToChannelMessage extends WorkerMessage { + static readonly pattern = 'AssignVariantToChannel'; +} +export class RemoveVariantFromChannelMessage extends WorkerMessage { + static readonly pattern = 'RemoveVariantFromChannel'; +} export class UpdateAssetMessage extends WorkerMessage { static readonly pattern = 'UpdateAsset'; } @@ -235,6 +248,8 @@ type UpdateAssetJobData = NamedJobData<'update-asset', UpdateAssetMessageData>; type DeleteAssetJobData = NamedJobData<'delete-asset', UpdateAssetMessageData>; type AssignProductToChannelJobData = NamedJobData<'assign-product-to-channel', ProductChannelMessageData>; type RemoveProductFromChannelJobData = NamedJobData<'remove-product-from-channel', ProductChannelMessageData>; +type AssignVariantToChannelJobData = NamedJobData<'assign-variant-to-channel', VariantChannelMessageData>; +type RemoveVariantFromChannelJobData = NamedJobData<'remove-variant-from-channel', VariantChannelMessageData>; export type UpdateIndexQueueJobData = | ReindexJobData | UpdateProductJobData @@ -245,7 +260,9 @@ export type UpdateIndexQueueJobData = | UpdateAssetJobData | DeleteAssetJobData | AssignProductToChannelJobData - | RemoveProductFromChannelJobData; + | RemoveProductFromChannelJobData + | AssignVariantToChannelJobData + | RemoveVariantFromChannelJobData; type CustomStringMapping = CustomMappingDefinition; type CustomStringMappingNullable = CustomMappingDefinition>;