Browse Source

Merge branch 'CU-9rvcxw/ProductVariants' into channel-aware-variants

# Conflicts:
#	schema-admin.json
Michael Bromley 5 years ago
parent
commit
50975a4f45
33 changed files with 1420 additions and 442 deletions
  1. 28 2
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 26 2
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  3. 28 2
      packages/common/src/generated-types.ts
  4. 7 125
      packages/core/e2e/channel.e2e-spec.ts
  5. 55 1
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  6. 4 0
      packages/core/e2e/graphql/fragments.ts
  7. 60 2
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  8. 18 0
      packages/core/e2e/graphql/shared-definitions.ts
  9. 369 0
      packages/core/e2e/product-channel.e2e-spec.ts
  10. 22 0
      packages/core/src/api/resolvers/admin/product.resolver.ts
  11. 14 2
      packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts
  12. 20 2
      packages/core/src/api/schema/admin-api/product.api.graphql
  13. 1 0
      packages/core/src/data-import/providers/importer/fast-importer.service.ts
  14. 9 2
      packages/core/src/entity/product-variant/product-variant.entity.ts
  15. 10 9
      packages/core/src/entity/product/product.entity.ts
  16. 23 0
      packages/core/src/event-bus/events/product-variant-channel-event.ts
  17. 1 0
      packages/core/src/event-bus/index.ts
  18. 1 1
      packages/core/src/job-queue/job.ts
  19. 16 0
      packages/core/src/plugin/default-search-plugin/default-search-plugin.ts
  20. 25 1
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  21. 31 5
      packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts
  22. 17 1
      packages/core/src/plugin/default-search-plugin/types.ts
  23. 133 30
      packages/core/src/service/services/product-variant.service.ts
  24. 23 34
      packages/core/src/service/services/product.service.ts
  25. 1 1
      packages/core/src/service/transaction/transactional-connection.ts
  26. 4 3
      packages/dev-server/dev-config.ts
  27. 192 0
      packages/elasticsearch-plugin/e2e/e2e-helpers.ts
  28. 149 208
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  29. 26 2
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  30. 31 5
      packages/elasticsearch-plugin/src/elasticsearch-index.service.ts
  31. 41 1
      packages/elasticsearch-plugin/src/indexer.controller.ts
  32. 17 0
      packages/elasticsearch-plugin/src/plugin.ts
  33. 18 1
      packages/elasticsearch-plugin/src/types.ts

+ 28 - 2
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<ProductVariant>;
+  /** Assigns all ProductVariants of Product to the specified Channel */
   assignProductsToChannel: Array<Product>;
   /** 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<ProductVariant>;
+  /** Removes all ProductVariants of Product from the specified Channel */
   removeProductsFromChannel: Array<Product>;
   /** 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<Channel>;
   id: Scalars['ID'];
   product: Product;
   productId: Scalars['ID'];
@@ -1795,6 +1810,17 @@ export type RemoveProductsFromChannelInput = {
   channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+  priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
   __typename?: 'ProductOptionInUseError';
   errorCode: ErrorCode;

+ 26 - 2
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -362,10 +362,14 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
-    /** Assigns Products to the specified Channel */
+    /** Assigns all ProductVariants of Product to the specified Channel */
     assignProductsToChannel: Array<Product>;
-    /** Removes Products from the specified Channel */
+    /** Removes all ProductVariants of Product from the specified Channel */
     removeProductsFromChannel: Array<Product>;
+    /** Assigns ProductVariants to the specified Channel */
+    assignProductVariantsToChannel: Array<ProductVariant>;
+    /** Removes ProductVariants from the specified Channel */
+    removeProductVariantsFromChannel: Array<ProductVariant>;
     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<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = {
     channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+    priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];

+ 28 - 2
packages/common/src/generated-types.ts

@@ -404,10 +404,14 @@ export type Mutation = {
   updateProductVariants: Array<Maybe<ProductVariant>>;
   /** Delete a ProductVariant */
   deleteProductVariant: DeletionResponse;
-  /** Assigns Products to the specified Channel */
+  /** Assigns all ProductVariants of Product to the specified Channel */
   assignProductsToChannel: Array<Product>;
-  /** Removes Products from the specified Channel */
+  /** Removes all ProductVariants of Product from the specified Channel */
   removeProductsFromChannel: Array<Product>;
+  /** Assigns ProductVariants to the specified Channel */
+  assignProductVariantsToChannel: Array<ProductVariant>;
+  /** Removes ProductVariants from the specified Channel */
+  removeProductVariantsFromChannel: Array<ProductVariant>;
   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<Channel>;
   id: Scalars['ID'];
   product: Product;
   productId: Scalars['ID'];
@@ -1764,6 +1779,17 @@ export type RemoveProductsFromChannelInput = {
   channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+  priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
   __typename?: 'ProductOptionInUseError';
   errorCode: ErrorCode;

+ 7 - 125
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<CreateChannel.Mutation, CreateChannel.Variables>(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<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
-                    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<UpdateGlobalLanguages.Mutation, UpdateGlobalLanguages.Variables>(
                 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<GetChannels.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,

+ 55 - 1
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', () => {

+ 4 - 0
packages/core/e2e/graphql/fragments.ts

@@ -80,6 +80,10 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
             languageCode
             name
         }
+        channels {
+            id
+            code
+        }
     }
     ${ASSET_FRAGMENT}
 `;

+ 60 - 2
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -362,10 +362,14 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
-    /** Assigns Products to the specified Channel */
+    /** Assigns all ProductVariants of Product to the specified Channel */
     assignProductsToChannel: Array<Product>;
-    /** Removes Products from the specified Channel */
+    /** Removes all ProductVariants of Product from the specified Channel */
     removeProductsFromChannel: Array<Product>;
+    /** Assigns ProductVariants to the specified Channel */
+    assignProductVariantsToChannel: Array<ProductVariant>;
+    /** Removes ProductVariants from the specified Channel */
+    removeProductVariantsFromChannel: Array<ProductVariant>;
     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<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = {
     channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+    priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];
@@ -4533,6 +4557,7 @@ export type ProductVariantFragment = Pick<
     featuredAsset?: Maybe<AssetFragment>;
     assets: Array<AssetFragment>;
     translations: Array<Pick<ProductVariantTranslation, 'id' | 'languageCode' | 'name'>>;
+    channels: Array<Pick<Channel, 'id' | 'code'>>;
 };
 
 export type ProductWithVariantsFragment = Pick<
@@ -4970,6 +4995,22 @@ export type RemoveProductsFromChannelMutation = {
     removeProductsFromChannel: Array<ProductWithVariantsFragment>;
 };
 
+export type AssignProductVariantsToChannelMutationVariables = Exact<{
+    input: AssignProductVariantsToChannelInput;
+}>;
+
+export type AssignProductVariantsToChannelMutation = {
+    assignProductVariantsToChannel: Array<ProductVariantFragment>;
+};
+
+export type RemoveProductVariantsFromChannelMutationVariables = Exact<{
+    input: RemoveProductVariantsFromChannelInput;
+}>;
+
+export type RemoveProductVariantsFromChannelMutation = {
+    removeProductVariantsFromChannel: Array<ProductVariantFragment>;
+};
+
 export type UpdateAssetMutationVariables = Exact<{
     input: UpdateAssetInput;
 }>;
@@ -6426,6 +6467,7 @@ export namespace ProductVariant {
     export type FeaturedAsset = NonNullable<ProductVariantFragment['featuredAsset']>;
     export type Assets = NonNullable<NonNullable<ProductVariantFragment['assets']>[number]>;
     export type Translations = NonNullable<NonNullable<ProductVariantFragment['translations']>[number]>;
+    export type Channels = NonNullable<NonNullable<ProductVariantFragment['channels']>[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<AssignProductVariantsToChannelMutation['assignProductVariantsToChannel']>[number]
+    >;
+}
+
+export namespace RemoveProductVariantsFromChannel {
+    export type Variables = RemoveProductVariantsFromChannelMutationVariables;
+    export type Mutation = RemoveProductVariantsFromChannelMutation;
+    export type RemoveProductVariantsFromChannel = NonNullable<
+        NonNullable<RemoveProductVariantsFromChannelMutation['removeProductVariantsFromChannel']>[number]
+    >;
+}
+
 export namespace UpdateAsset {
     export type Variables = UpdateAssetMutationVariables;
     export type Mutation = UpdateAssetMutation;

+ 18 - 0
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) {

+ 369 - 0
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<CreateChannel.Mutation, CreateChannel.Variables>(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<CreateChannel.Mutation, CreateChannel.Variables>(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<CreateRole.Mutation, CreateRole.Variables>(
+            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<CreateAdministrator.Mutation, CreateAdministrator.Variables>(
+            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<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
+                    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']);
+        });
+    });
+});

+ 22 - 0
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<Array<Translated<Product>>> {
         return this.productService.removeProductsFromChannel(ctx, args.input);
     }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async assignProductVariantsToChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationAssignProductVariantsToChannelArgs,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        return this.productVariantService.assignProductVariantsToChannel(ctx, args.input);
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async removeProductVariantsFromChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationRemoveProductVariantsFromChannelArgs,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        return this.productVariantService.removeProductVariantsFromChannel(ctx, args.input);
+    }
 }

+ 14 - 2
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<Channel[]> {
+        if (productVariant.channels) {
+            return productVariant.channels;
+        } else {
+            return this.productVariantService.getProductVariantChannels(ctx, productVariant.id);
+        }
+    }
 }

+ 20 - 2
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!

+ 1 - 0
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));

+ 9 - 2
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<ProductVariant>) {
         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[];
 }

+ 10 - 9
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<Product>) {
         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<Translation<Product>>;
 
-    @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[];
 }

+ 23 - 0
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();
+    }
+}

+ 1 - 0
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';

+ 1 - 1
packages/core/src/job-queue/job.ts

@@ -132,7 +132,7 @@ export class Job<T extends JobData<T> = 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');
     }
 

+ 16 - 0
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));

+ 25 - 1
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<AssignProductToChannelMessage['response']> {
+        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<RemoveProductFromChannelMessage['response']> {
+        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<UpdateAssetMessage['response']> {
         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()),

+ 31 - 5
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<any>, message: WorkerMessage<any, any>) {
         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: () => {

+ 17 - 1
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<ReindexMessageData, ReindexMessageResponse> {
     static readonly pattern = 'Reindex';
 }
@@ -67,6 +73,12 @@ export class AssignProductToChannelMessage extends WorkerMessage<ProductChannelM
 export class RemoveProductFromChannelMessage extends WorkerMessage<ProductChannelMessageData, boolean> {
     static readonly pattern = 'RemoveProductFromChannel';
 }
+export class AssignVariantToChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'AssignVariantToChannel';
+}
+export class RemoveVariantFromChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'RemoveVariantFromChannel';
+}
 export class UpdateAssetMessage extends WorkerMessage<UpdateAssetMessageData, boolean> {
     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;

+ 133 - 30
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<Translated<ProductVariant> | 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<Array<Translated<ProductVariant>>> {
         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<Array<Translated<ProductVariant>>> {
-        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<PaginatedList<Translated<ProductVariant>>> {
         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<Channel[]> {
+        const variant = await this.connection.getEntityOrThrow(ctx, ProductVariant, productVariantId, {
+            relations: ['channels'],
+            channelId: ctx.channelId,
+        });
+        return variant.channels;
+    }
+
     async getVariantByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise<Translated<ProductVariant>> {
         const { productVariant } = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLineId, {
             relations: ['productVariant'],
@@ -166,15 +184,17 @@ export class ProductVariantService {
 
     getOptionsForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<ProductOption>>> {
         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<Array<Translated<FacetValue>>> {
         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<ID> {
-        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<Array<Translated<ProductVariant>>> {
+        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<Array<Translated<ProductVariant>>> {
+        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

+ 23 - 34
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<Array<Translated<Product>>> {
-        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<Array<Translated<Product>>> {
-        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),
         );
     }
 

+ 1 - 1
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);
         }

+ 4 - 3
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,

+ 192 - 0
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<SearchProductsAdmin.Query, SearchProductsAdmin.Variables>(SEARCH_PRODUCTS, {
+        input,
+    });
+}
+
+export async function testGroupByProduct(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                groupByProduct: true,
+            },
+        },
+    );
+    expect(result.search.totalItems).toBe(20);
+}
+
+export async function testNoGrouping(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                groupByProduct: false,
+            },
+        },
+    );
+    expect(result.search.totalItems).toBe(34);
+}
+
+export async function testMatchSearchTerm(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        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<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        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<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        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<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        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<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        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<SearchGetPrices.Query, SearchGetPrices.Variables>(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<SearchGetPrices.Query, SearchGetPrices.Variables>(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);
+}

+ 149 - 208
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<SearchProductsAdmin.Query, SearchProductsAdmin.Variables>(SEARCH_PRODUCTS, {
-            input,
-        });
-    }
-
-    async function testGroupByProduct(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    groupByProduct: true,
-                },
-            },
-        );
-        expect(result.search.totalItems).toBe(20);
-    }
-
-    async function testNoGrouping(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    groupByProduct: false,
-                },
-            },
-        );
-        expect(result.search.totalItems).toBe(34);
-    }
-
-    async function testMatchSearchTerm(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            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<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            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<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            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<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            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<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            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<SearchGetPrices.Query, SearchGetPrices.Variables>(
-            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<SearchGetPrices.Query, SearchGetPrices.Variables>(
-            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<DeleteProductVariant.Mutation, DeleteProductVariant.Variables>(
                     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.Mutation>(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.Mutation>(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

+ 26 - 2
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -362,10 +362,14 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
-    /** Assigns Products to the specified Channel */
+    /** Assigns all ProductVariants of Product to the specified Channel */
     assignProductsToChannel: Array<Product>;
-    /** Removes Products from the specified Channel */
+    /** Removes all ProductVariants of Product from the specified Channel */
     removeProductsFromChannel: Array<Product>;
+    /** Assigns ProductVariants to the specified Channel */
+    assignProductVariantsToChannel: Array<ProductVariant>;
+    /** Removes ProductVariants from the specified Channel */
+    removeProductVariantsFromChannel: Array<ProductVariant>;
     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<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = {
     channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+    priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];

+ 31 - 5
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<any>, message: WorkerMessage<any, any>) {
         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: () => {

+ 41 - 1
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<AssignVariantToChannelMessage['response']> {
+        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<AssignVariantToChannelMessage['response']> {
+        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),

+ 17 - 0
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$

+ 18 - 1
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<Required<Asset>>;
@@ -210,6 +217,12 @@ export class AssignProductToChannelMessage extends WorkerMessage<ProductChannelM
 export class RemoveProductFromChannelMessage extends WorkerMessage<ProductChannelMessageData, boolean> {
     static readonly pattern = 'RemoveProductFromChannel';
 }
+export class AssignVariantToChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'AssignVariantToChannel';
+}
+export class RemoveVariantFromChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'RemoveVariantFromChannel';
+}
 export class UpdateAssetMessage extends WorkerMessage<UpdateAssetMessageData, boolean> {
     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<Args extends any[]> = CustomMappingDefinition<Args, 'String!', string>;
 type CustomStringMappingNullable<Args extends any[]> = CustomMappingDefinition<Args, 'String', Maybe<string>>;