Просмотр исходного кода

fix(core): Fix ChannelAware ProductVariant performance issues

hendrikdepauw 5 лет назад
Родитель
Сommit
275cd62acf

+ 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'];
@@ -490,6 +494,11 @@ export type MutationAddOptionGroupToProductArgs = {
 };
 
 
+export type MutationAssignProductVariantsToChannelArgs = {
+  input: AssignProductVariantsToChannelInput;
+};
+
+
 export type MutationAssignProductsToChannelArgs = {
   input: AssignProductsToChannelInput;
 };
@@ -763,6 +772,11 @@ export type MutationRemoveOptionGroupFromProductArgs = {
 };
 
 
+export type MutationRemoveProductVariantsFromChannelArgs = {
+  input: RemoveProductVariantsFromChannelInput;
+};
+
+
 export type MutationRemoveProductsFromChannelArgs = {
   input: RemoveProductsFromChannelInput;
 };
@@ -1663,6 +1677,7 @@ export type ProductVariant = Node & {
   outOfStockThreshold: Scalars['Int'];
   useGlobalOutOfStockThreshold: Scalars['Boolean'];
   stockMovements: StockMovementList;
+  channels: Array<Channel>;
   id: Scalars['ID'];
   product: Product;
   productId: Scalars['ID'];
@@ -1781,6 +1796,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

@@ -360,10 +360,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;
@@ -696,6 +700,14 @@ export type MutationRemoveProductsFromChannelArgs = {
     input: RemoveProductsFromChannelInput;
 };
 
+export type MutationAssignProductVariantsToChannelArgs = {
+    input: AssignProductVariantsToChannelInput;
+};
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+    input: RemoveProductVariantsFromChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1490,6 +1502,7 @@ export type ProductVariant = Node & {
     outOfStockThreshold: Scalars['Int'];
     useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
+    channels: Array<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1607,6 +1620,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

@@ -402,10 +402,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;
@@ -808,6 +812,16 @@ export type MutationRemoveProductsFromChannelArgs = {
 };
 
 
+export type MutationAssignProductVariantsToChannelArgs = {
+  input: AssignProductVariantsToChannelInput;
+};
+
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+  input: RemoveProductVariantsFromChannelInput;
+};
+
+
 export type MutationCreatePromotionArgs = {
   input: CreatePromotionInput;
 };
@@ -1632,6 +1646,7 @@ export type ProductVariant = Node & {
   outOfStockThreshold: Scalars['Int'];
   useGlobalOutOfStockThreshold: Scalars['Boolean'];
   stockMovements: StockMovementList;
+  channels: Array<Channel>;
   id: Scalars['ID'];
   product: Product;
   productId: Scalars['ID'];
@@ -1750,6 +1765,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;

+ 1 - 40
packages/core/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -18,46 +18,7 @@ Object {
   "name": "en Baked Potato",
   "optionGroups": Array [],
   "slug": "en-baked-potato",
-  "variants": Array [
-    Object {
-      "assets": Array [],
-      "currencyCode": "USD",
-      "enabled": true,
-      "facetValues": Array [],
-      "featuredAsset": null,
-      "id": "T_35",
-      "languageCode": "en",
-      "name": "Small Baked Potato",
-      "options": Array [],
-      "price": 0,
-      "priceIncludesTax": false,
-      "priceWithTax": 0,
-      "sku": "PV0",
-      "stockOnHand": 0,
-      "taxCategory": Object {
-        "id": "T_1",
-        "name": "Standard Tax",
-      },
-      "taxRateApplied": Object {
-        "id": "T_2",
-        "name": "Standard Tax Europe",
-        "value": 20,
-      },
-      "trackInventory": "INHERIT",
-      "translations": Array [
-        Object {
-          "id": "T_35",
-          "languageCode": "en",
-          "name": "Small Baked Potato",
-        },
-        Object {
-          "id": "T_36",
-          "languageCode": "de",
-          "name": "Klein baked Erdapfel",
-        },
-      ],
-    },
-  ],
+  "variants": Array [],
 }
 `;
 

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

+ 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

@@ -360,10 +360,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;
@@ -696,6 +700,14 @@ export type MutationRemoveProductsFromChannelArgs = {
     input: RemoveProductsFromChannelInput;
 };
 
+export type MutationAssignProductVariantsToChannelArgs = {
+    input: AssignProductVariantsToChannelInput;
+};
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+    input: RemoveProductVariantsFromChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1490,6 +1502,7 @@ export type ProductVariant = Node & {
     outOfStockThreshold: Scalars['Int'];
     useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
+    channels: Array<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1607,6 +1620,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'];
@@ -4514,6 +4538,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<
@@ -4951,6 +4976,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;
 }>;
@@ -6399,6 +6440,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 {
@@ -6828,6 +6870,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) {

+ 390 - 0
packages/core/e2e/product-channel.e2e-spec.ts

@@ -0,0 +1,390 @@
+/* tslint:disable:no-non-null-assertion */
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
+import {
+    createErrorResultGuard,
+    createTestEnvironment,
+    E2E_DEFAULT_CHANNEL_TOKEN,
+    ErrorResultGuard,
+} from '@vendure/testing';
+import gql from 'graphql-tag';
+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,
+    ChannelFragment,
+    CreateAdministrator,
+    CreateChannel,
+    CreateRole,
+    CurrencyCode,
+    DeleteChannel,
+    DeletionResult,
+    ErrorCode,
+    GetChannels,
+    GetCustomerList,
+    GetProductWithVariants,
+    LanguageCode,
+    Me,
+    Permission,
+    RemoveProductsFromChannel,
+    RemoveProductVariantsFromChannel,
+    UpdateChannel,
+    UpdateGlobalLanguages,
+} from './graphql/generated-e2e-admin-types';
+import {
+    ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
+    ASSIGN_PRODUCT_TO_CHANNEL,
+    CREATE_ADMINISTRATOR,
+    CREATE_CHANNEL,
+    CREATE_ROLE,
+    GET_CUSTOMER_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
+    ME,
+    REMOVE_PRODUCTVARIANT_FROM_CHANNEL,
+    REMOVE_PRODUCT_FROM_CHANNEL,
+    UPDATE_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.map(v => v.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.map(v => v.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']);
+        // });
+    });
+});

+ 0 - 37
packages/core/e2e/product.e2e-spec.ts

@@ -368,21 +368,6 @@ describe('Product resolver', () => {
                                 description: 'Eine baked Erdapfel',
                             },
                         ],
-                        variants: [
-                            {
-                                translations: [
-                                    {
-                                        languageCode: LanguageCode.en,
-                                        name: 'Small Baked Potato',
-                                    },
-                                    {
-                                        languageCode: LanguageCode.de,
-                                        name: 'Klein baked Erdapfel',
-                                    },
-                                ],
-                                sku: 'PV0',
-                            },
-                        ],
                     },
                 },
             );
@@ -415,17 +400,6 @@ describe('Product resolver', () => {
                                 description: 'A product with assets',
                             },
                         ],
-                        variants: [
-                            {
-                                translations: [
-                                    {
-                                        languageCode: LanguageCode.en,
-                                        name: 'A productVariant with assets',
-                                    },
-                                ],
-                                sku: 'PV0',
-                            },
-                        ],
                     },
                 },
             );
@@ -496,17 +470,6 @@ describe('Product resolver', () => {
                                 description: 'Another baked potato but a bit different',
                             },
                         ],
-                        variants: [
-                            {
-                                translations: [
-                                    {
-                                        languageCode: LanguageCode.en,
-                                        name: 'Another small Baked Potato',
-                                    },
-                                ],
-                                sku: 'PV0',
-                            },
-                        ],
                     },
                 },
             );

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

@@ -88,6 +88,10 @@ export class ProductAdminEntityResolver {
 
     @ResolveField()
     async channels(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise<Channel[]> {
-        return this.productService.getProductChannels(ctx, product.id);
+        if (product.channels) {
+            return product.channels;
+        } else {
+            return this.productService.getProductChannels(ctx, product.id);
+        }
     }
 }

+ 1 - 17
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -7,7 +7,7 @@ type Query {
 }
 
 type Mutation {
-    "Create a new Product. Must provide at least one ProductVariant"
+    "Create a new Product"
     createProduct(input: CreateProductInput!): Product!
 
     "Update an existing Product"
@@ -82,22 +82,6 @@ input CreateProductInput {
     assetIds: [ID!]
     facetValueIds: [ID!]
     translations: [ProductTranslationInput!]!
-    variants: [CreateProductProductVariantInput!]!
-}
-
-input CreateProductProductVariantInput {
-    translations: [ProductVariantTranslationInput!]!
-    facetValueIds: [ID!]
-    sku: String!
-    price: Int
-    taxCategoryId: ID
-    optionIds: [ID!]
-    featuredAssetId: ID
-    assetIds: [ID!]
-    stockOnHand: Int
-    outOfStockThreshold: Int
-    useGlobalOutOfStockThreshold: Boolean
-    trackInventory: GlobalFlag
 }
 
 input UpdateProductInput {

+ 1 - 0
packages/core/src/data-import/providers/importer/fast-importer.service.ts

@@ -52,6 +52,7 @@ export class FastImporterService {
             entityType: Product,
             translationType: ProductTranslation,
             beforeSave: async p => {
+                p.channels = [this.defaultChannel];
                 if (input.facetValueIds) {
                     p.facetValues = input.facetValueIds.map(id => ({ id } as any));
                 }

+ 0 - 1
packages/core/src/data-import/providers/importer/importer.ts

@@ -161,7 +161,6 @@ export class Importer {
                     },
                 ],
                 customFields: product.customFields,
-                variants: [],
             });
 
             const optionsMap: { [optionName: string]: ID } = {};

+ 9 - 2
packages/core/src/entity/product/product.entity.ts

@@ -1,11 +1,12 @@
 import { DeepPartial } 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 { CustomProductFields } from '../custom-entity-fields';
 import { FacetValue } from '../facet-value/facet-value.entity';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
@@ -22,7 +23,9 @@ import { ProductTranslation } from './product-translation.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Product extends VendureEntity implements Translatable, HasCustomFields, SoftDeletable {
+export class Product
+    extends VendureEntity
+    implements Translatable, HasCustomFields, ChannelAware, SoftDeletable {
     constructor(input?: DeepPartial<Product>) {
         super(input);
     }
@@ -60,4 +63,8 @@ export class Product extends VendureEntity implements Translatable, HasCustomFie
 
     @Column(type => CustomProductFields)
     customFields: CustomProductFields;
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 22 - 2
packages/core/src/service/services/product-variant.service.ts

@@ -463,6 +463,7 @@ export class ProductVariantService {
             .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,
@@ -472,7 +473,6 @@ export class ProductVariantService {
             );
             this.eventBus.publish(new ProductVariantChannelEvent(ctx, variant, input.channelId, 'assigned'));
         }
-
         return this.findByIds(
             ctx,
             variants.map(v => v.id),
@@ -499,9 +499,27 @@ export class ProductVariantService {
             .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),
@@ -513,6 +531,7 @@ export class ProductVariantService {
         // https://github.com/vendure-ecommerce/vendure/issues/328
         const optionGroups = (
             await this.connection.getEntityOrThrow(ctx, Product, input.productId, {
+                channelId: ctx.channelId,
                 relations: ['optionGroups', 'optionGroups.options'],
                 loadEagerRelations: false,
             })
@@ -533,6 +552,7 @@ export class ProductVariantService {
         }
 
         const product = await this.connection.getEntityOrThrow(ctx, Product, input.productId, {
+            channelId: ctx.channelId,
             relations: ['variants', 'variants.options'],
             loadEagerRelations: false,
         });

+ 22 - 47
packages/core/src/service/services/product.service.ts

@@ -9,7 +9,6 @@ import {
     UpdateProductInput,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
-import { unique } from '@vendure/common/lib/unique';
 import { FindOptionsUtils } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
@@ -42,7 +41,7 @@ import { TaxRateService } from './tax-rate.service';
 
 @Injectable()
 export class ProductService {
-    private readonly relations = ['featuredAsset', 'assets', 'facetValues', 'facetValues.facet'];
+    private readonly relations = ['featuredAsset', 'assets', 'channels', 'facetValues', 'facetValues.facet'];
 
     constructor(
         private connection: TransactionalConnection,
@@ -70,10 +69,6 @@ export class ProductService {
                 where: { deletedAt: null },
                 ctx,
             })
-            .leftJoin('product.variants', 'variant')
-            .innerJoinAndSelect('variant.channels', 'channel', 'channel.id = :channelId', {
-                channelId: ctx.channelId,
-            })
             .getManyAndCount()
             .then(async ([products, totalItems]) => {
                 const items = products.map(product =>
@@ -87,23 +82,16 @@ export class ProductService {
     }
 
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | undefined> {
-        const qb = this.connection.getRepository(ctx, Product).createQueryBuilder('product');
-        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations: this.relations });
-        // tslint:disable-next-line:no-non-null-assertion
-        FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
-        return qb
-            .leftJoin('product.variants', 'variant')
-            .innerJoinAndSelect('variant.channels', 'channel', 'channel.id = :channelId', {
-                channelId: ctx.channelId,
-            })
-            .andWhere('product.id = :productId', { productId })
-            .andWhere('product.deletedAt IS NULL')
-            .getOne()
-            .then(product => {
-                return product
-                    ? translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']])
-                    : undefined;
-            });
+        const product = await this.connection.findOneInChannel(ctx, Product, productId, ctx.channelId, {
+            relations: this.relations,
+            where: {
+                deletedAt: null,
+            },
+        });
+        if (!product) {
+            return;
+        }
+        return translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']]);
     }
 
     async findByIds(ctx: RequestContext, productIds: ID[]): Promise<Array<Translated<Product>>> {
@@ -112,12 +100,10 @@ export class ProductService {
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         return qb
-            .leftJoin('product.variants', 'variant')
-            .innerJoinAndSelect('variant.channels', 'channel', 'channel.id = :channelId', {
-                channelId: ctx.channelId,
-            })
+            .leftJoin('product.channels', 'channel')
             .andWhere('product.deletedAt IS NULL')
             .andWhere('product.id IN (:...ids)', { ids: productIds })
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
             .getMany()
             .then(products =>
                 products.map(product =>
@@ -126,19 +112,12 @@ export class ProductService {
             );
     }
 
-    // private async isProductInChannel(ctx: RequestContext, productId: ID, channelId: ID): Promise<boolean> {
-    //     const channelIds = (await this.getProductChannels(ctx, productId))
-    //         .map(channel => channel.id);
-    //     return channelIds.includes(channelId);
-    // }
-
     async getProductChannels(ctx: RequestContext, productId: ID): Promise<Channel[]> {
-        const productVariantChannels = ([] as Channel[]).concat(
-            ...(await this.productVariantService.getVariantsByProductId(ctx, productId)).map(
-                pv => pv.channels,
-            ),
-        );
-        return unique(productVariantChannels, 'code');
+        const product = await this.connection.getEntityOrThrow(ctx, Product, productId, {
+            relations: ['channels'],
+            channelId: ctx.channelId,
+        });
+        return product.channels;
     }
 
     getFacetValuesForProduct(ctx: RequestContext, productId: ID): Promise<Array<Translated<FacetValue>>> {
@@ -172,6 +151,7 @@ export class ProductService {
             entityType: Product,
             translationType: ProductTranslation,
             beforeSave: async p => {
+                this.channelService.assignToCurrentChannel(p, ctx);
                 if (input.facetValueIds) {
                     p.facetValues = await this.facetValueService.findByIds(ctx, input.facetValueIds);
                 }
@@ -180,12 +160,6 @@ export class ProductService {
         });
         await this.assetService.updateEntityAssets(ctx, product, input);
         this.eventBus.publish(new ProductEvent(ctx, product, 'created'));
-        await this.productVariantService.create(
-            ctx,
-            input.variants.map(variant => {
-                return { productId: product.id, ...variant };
-            }),
-        );
         return assertFound(this.findOne(ctx, product.id));
     }
 
@@ -210,8 +184,9 @@ export class ProductService {
     }
 
     async softDelete(ctx: RequestContext, productId: ID): Promise<DeletionResponse> {
-        // TODO: product should only be deleted if no active ProductVariants?
-        const product = await this.connection.getEntityOrThrow(ctx, Product, productId);
+        const product = await this.connection.getEntityOrThrow(ctx, Product, productId, {
+            channelId: ctx.channelId,
+        });
         product.deletedAt = new Date();
         await this.connection.getRepository(ctx, Product).save(product, { reload: false });
         this.eventBus.publish(new ProductEvent(ctx, product, 'deleted'));

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

@@ -360,10 +360,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;
@@ -696,6 +700,14 @@ export type MutationRemoveProductsFromChannelArgs = {
     input: RemoveProductsFromChannelInput;
 };
 
+export type MutationAssignProductVariantsToChannelArgs = {
+    input: AssignProductVariantsToChannelInput;
+};
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+    input: RemoveProductVariantsFromChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1490,6 +1502,7 @@ export type ProductVariant = Node & {
     outOfStockThreshold: Scalars['Int'];
     useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
+    channels: Array<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1607,6 +1620,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'];

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-admin.json


Некоторые файлы не были показаны из-за большого количества измененных файлов