Sfoglia il codice sorgente

feat(core): ChannelAware ProductVariants

hendrikdepauw 5 anni fa
parent
commit
4c1a2be7cb

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

@@ -18,7 +18,46 @@ Object {
   "name": "en Baked Potato",
   "optionGroups": Array [],
   "slug": "en-baked-potato",
-  "variants": Array [],
+  "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",
+        },
+      ],
+    },
+  ],
 }
 `;
 

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

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

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

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

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

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

+ 37 - 3
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -7,7 +7,7 @@ type Query {
 }
 
 type Mutation {
-    "Create a new Product"
+    "Create a new Product. Must provide at least one ProductVariant"
     createProduct(input: CreateProductInput!): Product!
 
     "Update an existing Product"
@@ -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 {
@@ -75,6 +82,22 @@ 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 {
@@ -147,6 +170,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 - 1
packages/core/src/data-import/providers/importer/fast-importer.service.ts

@@ -52,7 +52,6 @@ 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));
                 }
@@ -119,6 +118,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));

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

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

+ 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[];
 }

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

@@ -1,12 +1,11 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
-import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
+import { 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';
@@ -23,8 +22,7 @@ import { ProductTranslation } from './product-translation.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Product extends VendureEntity
-    implements Translatable, HasCustomFields, ChannelAware, SoftDeletable {
+export class Product extends VendureEntity implements Translatable, HasCustomFields, SoftDeletable {
     constructor(input?: DeepPartial<Product>) {
         super(input);
     }
@@ -41,29 +39,25 @@ 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)
-    @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 - 1
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -352,7 +352,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()),

+ 115 - 32
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,12 +446,73 @@ 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, 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]);
+            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
         const optionGroups = (
             await this.connection.getEntityOrThrow(ctx, Product, input.productId, {
-                channelId: ctx.channelId,
                 relations: ['optionGroups', 'optionGroups.options'],
                 loadEagerRelations: false,
             })
@@ -449,7 +533,6 @@ export class ProductVariantService {
         }
 
         const product = await this.connection.getEntityOrThrow(ctx, Product, input.productId, {
-            channelId: ctx.channelId,
             relations: ['variants', 'variants.options'],
             loadEagerRelations: false,
         });

+ 68 - 63
packages/core/src/service/services/product.service.ts

@@ -4,17 +4,17 @@ import {
     CreateProductInput,
     DeletionResponse,
     DeletionResult,
-    Permission,
     RemoveOptionGroupFromProductResult,
     RemoveProductsFromChannelInput,
     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';
 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';
@@ -25,7 +25,6 @@ import { ProductOptionGroup } from '../../entity/product-option-group/product-op
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
-import { ProductChannelEvent } from '../../event-bus/events/product-channel-event';
 import { ProductEvent } from '../../event-bus/events/product-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { SlugValidator } from '../helpers/slug-validator/slug-validator';
@@ -43,7 +42,7 @@ import { TaxRateService } from './tax-rate.service';
 
 @Injectable()
 export class ProductService {
-    private readonly relations = ['featuredAsset', 'assets', 'channels', 'facetValues', 'facetValues.facet'];
+    private readonly relations = ['featuredAsset', 'assets', 'facetValues', 'facetValues.facet'];
 
     constructor(
         private connection: TransactionalConnection,
@@ -71,6 +70,10 @@ 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 =>
@@ -84,16 +87,23 @@ export class ProductService {
     }
 
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | 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']]);
+        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;
+            });
     }
 
     async findByIds(ctx: RequestContext, productIds: ID[]): Promise<Array<Translated<Product>>> {
@@ -102,9 +112,12 @@ export class ProductService {
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         return qb
-            .leftJoin('product.channels', 'channel')
+            .leftJoin('product.variants', 'variant')
+            .innerJoinAndSelect('variant.channels', 'channel', 'channel.id = :channelId', {
+                channelId: ctx.channelId,
+            })
+            .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 =>
@@ -113,12 +126,19 @@ 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 product = await this.connection.getEntityOrThrow(ctx, Product, productId, {
-            relations: ['channels'],
-            channelId: ctx.channelId,
-        });
-        return product.channels;
+        const productVariantChannels = ([] as Channel[]).concat(
+            ...(await this.productVariantService.getVariantsByProductId(ctx, productId)).map(
+                pv => pv.channels,
+            ),
+        );
+        return unique(productVariantChannels, 'code');
     }
 
     getFacetValuesForProduct(ctx: RequestContext, productId: ID): Promise<Array<Translated<FacetValue>>> {
@@ -152,7 +172,6 @@ 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);
                 }
@@ -161,6 +180,12 @@ 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));
     }
 
@@ -185,9 +210,8 @@ export class ProductService {
     }
 
     async softDelete(ctx: RequestContext, productId: ID): Promise<DeletionResponse> {
-        const product = await this.connection.getEntityOrThrow(ctx, Product, productId, {
-            channelId: ctx.channelId,
-        });
+        // TODO: product should only be deleted if no active ProductVariants?
+        const product = await this.connection.getEntityOrThrow(ctx, Product, productId);
         product.deletedAt = new Date();
         await this.connection.getRepository(ctx, Product).save(product, { reload: false });
         this.eventBus.publish(new ProductEvent(ctx, product, 'deleted'));
@@ -200,32 +224,18 @@ 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,
-                );
-            }
-            this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'assigned'));
-        }
+        await this.productVariantService.assignProductVariantsToChannel(ctx, {
+            productVariantIds: ([] as ID[]).concat(
+                ...productsWithVariants.map(p => p.variants.map(v => v.id)),
+            ),
+            channelId: input.channelId,
+            priceFactor: input.priceFactor,
+        });
         return this.findByIds(
             ctx,
             productsWithVariants.map(p => p.id),
@@ -236,25 +246,20 @@ 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 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'));
-        }
+        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,
+        });
         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);
         }