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

feat(core): Implement `assignProductsToChannel` mutation

Relates to #12
Michael Bromley 6 лет назад
Родитель
Сommit
5fda66b482

+ 136 - 2
packages/core/e2e/channel.e2e-spec.ts

@@ -1,25 +1,44 @@
 /* tslint:disable:no-non-null-assertion */
-import { createTestEnvironment } from '@vendure/testing';
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import gql from 'graphql-tag';
 import path from 'path';
 
 import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
 import { initialData } from './fixtures/e2e-initial-data';
+import { PRODUCT_WITH_VARIANTS_FRAGMENT } from './graphql/fragments';
 import {
+    AssignProductsToChannel,
     CreateAdministrator,
     CreateChannel,
+    CreateProduct,
     CreateRole,
     CurrencyCode,
+    GetCustomerList,
+    GetProductWithVariants,
     LanguageCode,
     Me,
     Permission,
+    UpdateProduct,
 } from './graphql/generated-e2e-admin-types';
-import { CREATE_ADMINISTRATOR, CREATE_CHANNEL, CREATE_ROLE, ME } from './graphql/shared-definitions';
+import {
+    CREATE_ADMINISTRATOR,
+    CREATE_CHANNEL,
+    CREATE_PRODUCT,
+    CREATE_ROLE,
+    GET_CUSTOMER_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
+    ME,
+    UPDATE_PRODUCT,
+} from './graphql/shared-definitions';
 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;
 
     beforeAll(async () => {
         await server.init({
@@ -29,6 +48,14 @@ describe('Channels', () => {
             customerCount: 1,
         });
         await adminClient.asSuperAdmin();
+
+        const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            GET_CUSTOMER_LIST,
+            {
+                options: { take: 1 },
+            },
+        );
+        customerUser = customers.items[0];
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -77,6 +104,27 @@ describe('Channels', () => {
         expect(secondChannelData!.permissions).toEqual(nonOwnerPermissions);
     });
 
+    it('customer has Authenticated permission on new channel', async () => {
+        await shopClient.asUserWithCredentials(customerUser.emailAddress, 'test');
+        const { me } = await shopClient.query<Me.Query>(ME);
+
+        expect(me!.channels.length).toBe(2);
+
+        const secondChannelData = me!.channels.find(c => c.token === SECOND_CHANNEL_TOKEN);
+        expect(me!.channels).toEqual([
+            {
+                code: DEFAULT_CHANNEL_CODE,
+                permissions: ['Authenticated'],
+                token: E2E_DEFAULT_CHANNEL_TOKEN,
+            },
+            {
+                code: 'second-channel',
+                permissions: ['Authenticated'],
+                token: SECOND_CHANNEL_TOKEN,
+            },
+        ]);
+    });
+
     it('createRole on second Channel', async () => {
         const { createRole } = await adminClient.query<CreateRole.Mutation, CreateRole.Variables>(
             CREATE_ROLE,
@@ -181,4 +229,90 @@ 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 () => {
+                expect(product1).toBeDefined();
+                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)).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),
+            );
+        });
+    });
 });
+
+const ASSIGN_PRODUCT_TO_CHANNEL = gql`
+    mutation AssignProductsToChannel($input: AssignProductsToChannelInput!) {
+        assignProductsToChannel(input: $input) {
+            ...ProductWithVariants
+        }
+    }
+    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
+`;

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

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

+ 29 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -162,6 +162,12 @@ export enum AssetType {
     BINARY = 'BINARY',
 }
 
+export type AssignProductsToChannelInput = {
+    productIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+    priceFactor?: Maybe<Scalars['Float']>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -1761,6 +1767,8 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
+    /** Assigns Products to the specified Channel */
+    assignProductsToChannel: Array<Product>;
     createPromotion: Promotion;
     updatePromotion: Promotion;
     deletePromotion: DeletionResponse;
@@ -2009,6 +2017,10 @@ export type MutationDeleteProductVariantArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationAssignProductsToChannelArgs = {
+    input: AssignProductsToChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -2337,6 +2349,7 @@ export type PriceRange = {
 export type Product = Node & {
     __typename?: 'Product';
     enabled: Scalars['Boolean'];
+    channels: Array<Channel>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -3404,6 +3417,14 @@ export type GetCustomerCountQuery = { __typename?: 'Query' } & {
     customers: { __typename?: 'CustomerList' } & Pick<CustomerList, 'totalItems'>;
 };
 
+export type AssignProductsToChannelMutationVariables = {
+    input: AssignProductsToChannelInput;
+};
+
+export type AssignProductsToChannelMutation = { __typename?: 'Mutation' } & {
+    assignProductsToChannel: Array<{ __typename?: 'Product' } & ProductWithVariantsFragment>;
+};
+
 export type GetCollectionsWithAssetsQueryVariables = {};
 
 export type GetCollectionsWithAssetsQuery = { __typename?: 'Query' } & {
@@ -3925,6 +3946,7 @@ export type ProductWithVariantsFragment = { __typename?: 'Product' } & Pick<
                     facet: { __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>;
                 }
         >;
+        channels: Array<{ __typename?: 'Channel' } & Pick<Channel, 'id' | 'code'>>;
     };
 
 export type RoleFragment = { __typename?: 'Role' } & Pick<
@@ -5064,6 +5086,12 @@ export namespace GetCustomerCount {
     export type Customers = GetCustomerCountQuery['customers'];
 }
 
+export namespace AssignProductsToChannel {
+    export type Variables = AssignProductsToChannelMutationVariables;
+    export type Mutation = AssignProductsToChannelMutation;
+    export type AssignProductsToChannel = ProductWithVariantsFragment;
+}
+
 export namespace GetCollectionsWithAssets {
     export type Variables = GetCollectionsWithAssetsQueryVariables;
     export type Query = GetCollectionsWithAssetsQuery;
@@ -5417,6 +5445,7 @@ export namespace ProductWithVariants {
     export type Variants = ProductVariantFragment;
     export type FacetValues = NonNullable<ProductWithVariantsFragment['facetValues'][0]>;
     export type Facet = (NonNullable<ProductWithVariantsFragment['facetValues'][0]>)['facet'];
+    export type Channels = NonNullable<ProductWithVariantsFragment['channels'][0]>;
 }
 
 export namespace Role {

+ 5 - 2
packages/core/src/api/api-internal-modules.ts

@@ -35,7 +35,10 @@ import { FulfillmentEntityResolver } from './resolvers/entity/fulfillment-entity
 import { OrderEntityResolver } from './resolvers/entity/order-entity.resolver';
 import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.resolver';
 import { PaymentEntityResolver } from './resolvers/entity/payment-entity.resolver';
-import { ProductEntityResolver } from './resolvers/entity/product-entity.resolver';
+import {
+    ProductAdminEntityResolver,
+    ProductEntityResolver,
+} from './resolvers/entity/product-entity.resolver';
 import { ProductOptionGroupEntityResolver } from './resolvers/entity/product-option-group-entity.resolver';
 import {
     ProductVariantAdminEntityResolver,
@@ -95,7 +98,7 @@ export const entityResolvers = [
     RefundEntityResolver,
 ];
 
-export const adminEntityResolvers = [ProductVariantAdminEntityResolver];
+export const adminEntityResolvers = [ProductVariantAdminEntityResolver, ProductAdminEntityResolver];
 
 /**
  * The internal module containing some shared providers used by more than

+ 10 - 0
packages/core/src/api/resolvers/admin/product.resolver.ts

@@ -2,6 +2,7 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     DeletionResponse,
     MutationAddOptionGroupToProductArgs,
+    MutationAssignProductsToChannelArgs,
     MutationCreateProductArgs,
     MutationCreateProductVariantsArgs,
     MutationDeleteProductArgs,
@@ -139,4 +140,13 @@ export class ProductResolver {
     ): Promise<DeletionResponse> {
         return this.productVariantService.softDelete(ctx, args.id);
     }
+
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async assignProductsToChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationAssignProductsToChannelArgs,
+    ): Promise<Array<Translated<Product>>> {
+        return this.productService.assignProductsToChannel(ctx, args.input);
+    }
 }

+ 17 - 0
packages/core/src/api/resolvers/entity/product-entity.resolver.ts

@@ -1,7 +1,9 @@
 import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { ID } from '@vendure/common/lib/shared-types';
 
 import { Translated } from '../../../common/types/locale-types';
 import { Asset } from '../../../entity/asset/asset.entity';
+import { Channel } from '../../../entity/channel/channel.entity';
 import { Collection } from '../../../entity/collection/collection.entity';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
@@ -10,6 +12,7 @@ import { AssetService } from '../../../service/services/asset.service';
 import { CollectionService } from '../../../service/services/collection.service';
 import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { ProductService } from '../../../service/services/product.service';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { Api } from '../../decorators/api.decorator';
@@ -64,3 +67,17 @@ export class ProductEntityResolver {
         return this.assetService.getEntityAssets(product);
     }
 }
+
+@Resolver('Product')
+export class ProductAdminEntityResolver {
+    constructor(private productService: ProductService) {}
+
+    @ResolveProperty()
+    async channels(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise<Channel[]> {
+        if (product.channels) {
+            return product.channels;
+        } else {
+            return this.productService.getProductChannels(product.id);
+        }
+    }
+}

+ 10 - 0
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -28,10 +28,14 @@ type Mutation {
 
     "Delete a ProductVariant"
     deleteProductVariant(id: ID!): DeletionResponse!
+
+    "Assigns Products to the specified Channel"
+    assignProductsToChannel(input: AssignProductsToChannelInput!): [Product!]!
 }
 
 type Product implements Node {
     enabled: Boolean!
+    channels: [Channel!]!
 }
 
 type ProductVariant implements Node {
@@ -119,3 +123,9 @@ input UpdateProductVariantInput {
     stockOnHand: Int
     trackInventory: Boolean
 }
+
+input AssignProductsToChannelInput {
+    productIds: [ID!]!
+    channelId: ID!
+    priceFactor: Float
+}

+ 7 - 5
packages/core/src/service/services/channel.service.ts

@@ -44,18 +44,20 @@ export class ChannelService {
     }
 
     /**
-     * Assigns the entity to the given Channel and saves.
+     * Assigns the entity to the given Channels and saves.
      */
-    async assignToChannel<T extends ChannelAware & VendureEntity>(
+    async assignToChannels<T extends ChannelAware & VendureEntity>(
         entityType: Type<T>,
         entityId: ID,
-        channelId: ID,
+        channelIds: ID[],
     ): Promise<T> {
         const entity = await getEntityOrThrow(this.connection, entityType, entityId, {
             relations: ['channels'],
         });
-        const channel = await getEntityOrThrow(this.connection, Channel, channelId);
-        entity.channels.push(channel);
+        for (const id of channelIds) {
+            const channel = await getEntityOrThrow(this.connection, Channel, id);
+            entity.channels.push(channel);
+        }
         await this.connection.getRepository(entityType).save(entity as any);
         return entity;
     }

+ 18 - 6
packages/core/src/service/services/product-variant.service.ts

@@ -191,12 +191,8 @@ export class ProductVariantService {
                 input.stockOnHand,
             );
         }
-        const variantPrice = new ProductVariantPrice({
-            price: createdVariant.price,
-            channelId: ctx.channelId,
-        });
-        variantPrice.variant = createdVariant;
-        await this.connection.getRepository(ProductVariantPrice).save(variantPrice);
+
+        await this.createProductVariantPrice(createdVariant.id, createdVariant.price, ctx.channelId);
         this.eventBus.publish(new CatalogModificationEvent(ctx, createdVariant));
         return await assertFound(this.findOne(ctx, createdVariant.id));
     }
@@ -269,6 +265,22 @@ export class ProductVariantService {
         ]);
     }
 
+    /**
+     * Creates a ProductVariantPrice for the given ProductVariant/Channel combination.
+     */
+    async createProductVariantPrice(
+        productVariantId: ID,
+        price: number,
+        channelId: ID,
+    ): Promise<ProductVariantPrice> {
+        const variantPrice = new ProductVariantPrice({
+            price,
+            channelId,
+        });
+        variantPrice.variant = new ProductVariant({ id: productVariantId });
+        return this.connection.getRepository(ProductVariantPrice).save(variantPrice);
+    }
+
     async softDelete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
         const variant = await getEntityOrThrow(this.connection, ProductVariant, id);
         variant.deletedAt = new Date();

+ 50 - 4
packages/core/src/service/services/product.service.ts

@@ -1,9 +1,11 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import {
+    AssignProductsToChannelInput,
     CreateProductInput,
     DeletionResponse,
     DeletionResult,
+    Permission,
     UpdateProductInput,
 } from '@vendure/common/lib/generated-types';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
@@ -11,10 +13,11 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError, UserInputError } from '../../common/error/errors';
+import { EntityNotFoundError, ForbiddenError, UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
+import { Channel } from '../../entity/channel/channel.entity';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { Product } from '../../entity/product/product.entity';
@@ -30,6 +33,7 @@ import { ChannelService } from './channel.service';
 import { CollectionService } from './collection.service';
 import { FacetValueService } from './facet-value.service';
 import { ProductVariantService } from './product-variant.service';
+import { RoleService } from './role.service';
 import { TaxRateService } from './tax-rate.service';
 
 @Injectable()
@@ -39,6 +43,7 @@ export class ProductService {
     constructor(
         @InjectConnection() private connection: Connection,
         private channelService: ChannelService,
+        private roleService: RoleService,
         private assetService: AssetService,
         private productVariantService: ProductVariantService,
         private facetValueService: FacetValueService,
@@ -84,6 +89,13 @@ export class ProductService {
         return translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']]);
     }
 
+    async getProductChannels(productId: ID): Promise<Channel[]> {
+        const product = await getEntityOrThrow(this.connection, Product, productId, {
+            relations: ['channels'],
+        });
+        return product.channels;
+    }
+
     async findOneBySlug(ctx: RequestContext, slug: string): Promise<Translated<Product> | undefined> {
         const translation = await this.connection.getRepository(ProductTranslation).findOne({
             where: {
@@ -145,9 +157,43 @@ export class ProductService {
         };
     }
 
-    async assignToChannel(ctx: RequestContext, productId: ID, channelId: ID): Promise<Translated<Product>> {
-        const product = await this.channelService.assignToChannel(Product, productId, channelId);
-        return assertFound(this.findOne(ctx, product.id));
+    async assignProductsToChannel(
+        ctx: RequestContext,
+        input: AssignProductsToChannelInput,
+    ): Promise<Array<Translated<Product>>> {
+        const hasPermission = await this.roleService.userHasPermissionOnChannel(
+            ctx.activeUserId,
+            input.channelId,
+            Permission.UpdateCatalog,
+        );
+        if (!hasPermission) {
+            throw new ForbiddenError();
+        }
+        const productsWithVariants = await this.connection
+            .getRepository(Product)
+            .findByIds(input.productIds, {
+                relations: ['variants'],
+            });
+        const priceFactor = input.priceFactor != null ? input.priceFactor : 1;
+        for (const product of productsWithVariants) {
+            await this.channelService.assignToChannels(Product, product.id, [input.channelId]);
+            for (const variant of product.variants) {
+                await this.productVariantService.createProductVariantPrice(
+                    variant.id,
+                    variant.price * priceFactor,
+                    input.channelId,
+                );
+            }
+        }
+
+        return this.connection
+            .getRepository(Product)
+            .findByIds(productsWithVariants.map(p => p.id), { relations: this.relations })
+            .then(products =>
+                products.map(product =>
+                    translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']]),
+                ),
+            );
     }
 
     async addOptionGroupToProduct(

+ 9 - 2
packages/core/src/service/services/role.service.ts

@@ -87,7 +87,14 @@ export class RoleService {
     /**
      * Returns true if the User has the specified permission on that Channel
      */
-    async userHasPermissionOnChannel(userId: ID, channelId: ID, permission: Permission): Promise<boolean> {
+    async userHasPermissionOnChannel(
+        userId: ID | null | undefined,
+        channelId: ID,
+        permission: Permission,
+    ): Promise<boolean> {
+        if (userId == null) {
+            return false;
+        }
         const user = await getEntityOrThrow(this.connection, User, userId, {
             relations: ['roles', 'roles.channels'],
         });
@@ -138,7 +145,7 @@ export class RoleService {
     }
 
     async assignRoleToChannel(roleId: ID, channelId: ID) {
-        await this.channelService.assignToChannel(Role, roleId, channelId);
+        await this.channelService.assignToChannels(Role, roleId, [channelId]);
     }
 
     private async getPermittedChannels(channelIds: ID[], activeUserId: ID): Promise<Channel[]> {

+ 3 - 1
packages/testing/src/config/test-config.ts

@@ -9,6 +9,8 @@ import { TestingAssetPreviewStrategy } from './testing-asset-preview-strategy';
 import { TestingAssetStorageStrategy } from './testing-asset-storage-strategy';
 import { TestingEntityIdStrategy } from './testing-entity-id-strategy';
 
+export const E2E_DEFAULT_CHANNEL_TOKEN = 'e2e-default-channel';
+
 /**
  * @description
  * A {@link VendureConfig} object used for e2e tests. This configuration uses sqljs as the database
@@ -27,7 +29,7 @@ export const testConfig: Required<VendureConfig> = mergeConfig(defaultConfig, {
     adminApiPath: ADMIN_API_PATH,
     shopApiPath: SHOP_API_PATH,
     cors: true,
-    defaultChannelToken: 'e2e-default-channel',
+    defaultChannelToken: E2E_DEFAULT_CHANNEL_TOKEN,
     authOptions: {
         sessionSecret: 'some-secret',
         tokenMethod: 'bearer',

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


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