Răsfoiți Sursa

feat(core): Add Promotion Channel mutations to Admin API

Michael Bromley 4 ani în urmă
părinte
comite
ff051ae7c3

+ 24 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -333,6 +333,8 @@ export type Mutation = {
   assignProductVariantsToChannel: Array<ProductVariant>;
   /** Assigns all ProductVariants of Product to the specified Channel */
   assignProductsToChannel: Array<Product>;
+  /** Assigns Promotions to the specified Channel */
+  assignPromotionsToChannel: Array<Promotion>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
   /** Authenticates the user using a named authentication strategy */
@@ -446,6 +448,8 @@ export type Mutation = {
   removeProductVariantsFromChannel: Array<ProductVariant>;
   /** Removes all ProductVariants of Product from the specified Channel */
   removeProductsFromChannel: Array<Product>;
+  /** Removes Promotions from the specified Channel */
+  removePromotionsFromChannel: Array<Promotion>;
   /** Remove all settled jobs in the given queues olfer than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
   requestCompleted: Scalars['Int'];
@@ -566,6 +570,11 @@ export type MutationAssignProductsToChannelArgs = {
 };
 
 
+export type MutationAssignPromotionsToChannelArgs = {
+  input: AssignPromotionsToChannelInput;
+};
+
+
 export type MutationAssignRoleToAdministratorArgs = {
   administratorId: Scalars['ID'];
   roleId: Scalars['ID'];
@@ -867,6 +876,11 @@ export type MutationRemoveProductsFromChannelArgs = {
 };
 
 
+export type MutationRemovePromotionsFromChannelArgs = {
+  input: RemovePromotionsFromChannelInput;
+};
+
+
 export type MutationRemoveSettledJobsArgs = {
   queueNames?: Maybe<Array<Scalars['String']>>;
   olderThan?: Maybe<Scalars['DateTime']>;
@@ -2285,6 +2299,16 @@ export type UpdatePromotionInput = {
   actions?: Maybe<Array<ConfigurableOperationInput>>;
 };
 
+export type AssignPromotionsToChannelInput = {
+  promotionIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
+export type RemovePromotionsFromChannelInput = {
+  promotionIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 /** Returned if a PromotionCondition has neither a couponCode nor any conditions set */
 export type MissingConditionsError = ErrorResult & {
   __typename?: 'MissingConditionsError';

Fișier diff suprimat deoarece este prea mare
+ 513 - 645
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts


+ 24 - 0
packages/common/src/generated-types.ts

@@ -458,6 +458,10 @@ export type Mutation = {
   createPromotion: CreatePromotionResult;
   updatePromotion: UpdatePromotionResult;
   deletePromotion: DeletionResponse;
+  /** Assigns Promotions to the specified Channel */
+  assignPromotionsToChannel: Array<Promotion>;
+  /** Removes Promotions from the specified Channel */
+  removePromotionsFromChannel: Array<Promotion>;
   /** Create a new Role */
   createRole: Role;
   /** Update an existing Role */
@@ -922,6 +926,16 @@ export type MutationDeletePromotionArgs = {
 };
 
 
+export type MutationAssignPromotionsToChannelArgs = {
+  input: AssignPromotionsToChannelInput;
+};
+
+
+export type MutationRemovePromotionsFromChannelArgs = {
+  input: RemovePromotionsFromChannelInput;
+};
+
+
 export type MutationCreateRoleArgs = {
   input: CreateRoleInput;
 };
@@ -2248,6 +2262,16 @@ export type UpdatePromotionInput = {
   actions?: Maybe<Array<ConfigurableOperationInput>>;
 };
 
+export type AssignPromotionsToChannelInput = {
+  promotionIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
+export type RemovePromotionsFromChannelInput = {
+  promotionIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 /** Returned if a PromotionCondition has neither a couponCode nor any conditions set */
 export type MissingConditionsError = ErrorResult & {
   __typename?: 'MissingConditionsError';

Fișier diff suprimat deoarece este prea mare
+ 513 - 645
packages/core/e2e/graphql/generated-e2e-admin-types.ts


+ 18 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -880,3 +880,21 @@ export const DELETE_SHIPPING_METHOD = gql`
         }
     }
 `;
+
+export const ASSIGN_PROMOTIONS_TO_CHANNEL = gql`
+    mutation AssignPromotionToChannel($input: AssignPromotionsToChannelInput!) {
+        assignPromotionsToChannel(input: $input) {
+            id
+            name
+        }
+    }
+`;
+
+export const REMOVE_PROMOTIONS_FROM_CHANNEL = gql`
+    mutation RemovePromotionFromChannel($input: RemovePromotionsFromChannelInput!) {
+        removePromotionsFromChannel(input: $input) {
+            id
+            name
+        }
+    }
+`;

+ 22 - 1
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -30,6 +30,7 @@ import { orderFixedDiscount } from '../src/config/promotion/actions/order-fixed-
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import {
     AssignProductsToChannel,
+    AssignPromotionToChannel,
     ChannelFragment,
     CreateChannel,
     CreateCustomerGroup,
@@ -62,6 +63,7 @@ import {
 } from './graphql/generated-e2e-shop-types';
 import {
     ASSIGN_PRODUCT_TO_CHANNEL,
+    ASSIGN_PROMOTIONS_TO_CHANNEL,
     CREATE_CHANNEL,
     CREATE_CUSTOMER_GROUP,
     CREATE_PROMOTION,
@@ -515,6 +517,7 @@ describe('Promotions applied to Orders', () => {
 
     describe('default PromotionActions', () => {
         const TAX_INCLUDED_CHANNEL_TOKEN = 'tax_included_channel';
+        let taxIncludedChannel: ChannelFragment;
 
         beforeAll(async () => {
             // Create a channel where the prices include tax, so we can ensure
@@ -533,7 +536,7 @@ describe('Promotions applied to Orders', () => {
                     token: TAX_INCLUDED_CHANNEL_TOKEN,
                 },
             });
-            const taxIncludedChannel = createChannel as ChannelFragment;
+            taxIncludedChannel = createChannel as ChannelFragment;
             await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                 ASSIGN_PRODUCT_TO_CHANNEL,
                 {
@@ -550,6 +553,18 @@ describe('Promotions applied to Orders', () => {
             await shopClient.asAnonymousUser();
         });
 
+        async function assignPromotionToTaxIncludedChannel(promotionId: string | string[]) {
+            await adminClient.query<AssignPromotionToChannel.Mutation, AssignPromotionToChannel.Variables>(
+                ASSIGN_PROMOTIONS_TO_CHANNEL,
+                {
+                    input: {
+                        promotionIds: Array.isArray(promotionId) ? promotionId : [promotionId],
+                        channelId: taxIncludedChannel.id,
+                    },
+                },
+            );
+        }
+
         describe('orderPercentageDiscount', () => {
             const couponCode = '50%_off_order';
             let promotion: PromotionFragment;
@@ -567,6 +582,7 @@ describe('Promotions applied to Orders', () => {
                         },
                     ],
                 });
+                await assignPromotionToTaxIncludedChannel(promotion.id);
             });
 
             afterAll(async () => {
@@ -641,6 +657,7 @@ describe('Promotions applied to Orders', () => {
                         },
                     ],
                 });
+                await assignPromotionToTaxIncludedChannel(promotion.id);
             });
 
             afterAll(async () => {
@@ -730,6 +747,7 @@ describe('Promotions applied to Orders', () => {
                         },
                     ],
                 });
+                await assignPromotionToTaxIncludedChannel(promotion.id);
             });
 
             afterAll(async () => {
@@ -866,6 +884,7 @@ describe('Promotions applied to Orders', () => {
                         },
                     ],
                 });
+                await assignPromotionToTaxIncludedChannel(promotion.id);
             });
 
             afterAll(async () => {
@@ -983,6 +1002,7 @@ describe('Promotions applied to Orders', () => {
                         },
                     ],
                 });
+                await assignPromotionToTaxIncludedChannel(promotion.id);
             });
 
             afterAll(async () => {
@@ -1104,6 +1124,7 @@ describe('Promotions applied to Orders', () => {
                         },
                     ],
                 });
+                await assignPromotionToTaxIncludedChannel([promotion1.id, promotion2.id]);
             });
 
             afterAll(async () => {

+ 115 - 2
packages/core/e2e/promotion.e2e-spec.ts

@@ -1,6 +1,11 @@
 import { pick } from '@vendure/common/lib/pick';
 import { PromotionAction, PromotionCondition, PromotionOrderAction } from '@vendure/core';
-import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import {
+    createErrorResultGuard,
+    createTestEnvironment,
+    E2E_DEFAULT_CHANNEL_TOKEN,
+    ErrorResultGuard,
+} from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -9,7 +14,11 @@ import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-conf
 
 import { PROMOTION_FRAGMENT } from './graphql/fragments';
 import {
+    AssignPromotionToChannel,
+    ChannelFragment,
+    CreateChannel,
     CreatePromotion,
+    CurrencyCode,
     DeletePromotion,
     DeletionResult,
     ErrorCode,
@@ -19,9 +28,15 @@ import {
     LanguageCode,
     Promotion,
     PromotionFragment,
+    RemovePromotionFromChannel,
     UpdatePromotion,
 } from './graphql/generated-e2e-admin-types';
-import { CREATE_PROMOTION } from './graphql/shared-definitions';
+import {
+    ASSIGN_PROMOTIONS_TO_CHANNEL,
+    CREATE_CHANNEL,
+    CREATE_PROMOTION,
+    REMOVE_PROMOTIONS_FROM_CHANNEL,
+} from './graphql/shared-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
@@ -207,11 +222,109 @@ describe('Promotion resolver', () => {
         expect(result.promotionConditions).toMatchSnapshot();
     });
 
+    describe('channels', () => {
+        const SECOND_CHANNEL_TOKEN = 'SECOND_CHANNEL_TOKEN';
+        let secondChannel: ChannelFragment;
+        beforeAll(async () => {
+            const { createChannel } = await adminClient.query<
+                CreateChannel.Mutation,
+                CreateChannel.Variables
+            >(CREATE_CHANNEL, {
+                input: {
+                    code: 'second-channel',
+                    token: SECOND_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    pricesIncludeTax: true,
+                    currencyCode: CurrencyCode.EUR,
+                    defaultTaxZoneId: 'T_1',
+                    defaultShippingZoneId: 'T_2',
+                },
+            });
+            secondChannel = createChannel as any;
+        });
+
+        it('does not list Promotions not in active channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { promotions } = await adminClient.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
+
+            expect(promotions.totalItems).toBe(0);
+            expect(promotions.items).toEqual([]);
+        });
+
+        it('does not return Promotion not in active channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { promotion: result } = await adminClient.query<GetPromotion.Query, GetPromotion.Variables>(
+                GET_PROMOTION,
+                {
+                    id: promotion.id,
+                },
+            );
+
+            expect(result).toBeNull();
+        });
+
+        it('assignPromotionsToChannel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { assignPromotionsToChannel } = await adminClient.query<
+                AssignPromotionToChannel.Mutation,
+                AssignPromotionToChannel.Variables
+            >(ASSIGN_PROMOTIONS_TO_CHANNEL, {
+                input: {
+                    channelId: secondChannel.id,
+                    promotionIds: [promotion.id],
+                },
+            });
+
+            expect(assignPromotionsToChannel).toEqual([{ id: promotion.id, name: promotion.name }]);
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { promotion: result } = await adminClient.query<GetPromotion.Query, GetPromotion.Variables>(
+                GET_PROMOTION,
+                {
+                    id: promotion.id,
+                },
+            );
+            expect(result?.id).toBe(promotion.id);
+
+            const { promotions } = await adminClient.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
+            expect(promotions.totalItems).toBe(1);
+            expect(promotions.items.map(pick(['id']))).toEqual([{ id: promotion.id }]);
+        });
+
+        it('removePromotionsFromChannel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { removePromotionsFromChannel } = await adminClient.query<
+                RemovePromotionFromChannel.Mutation,
+                RemovePromotionFromChannel.Variables
+            >(REMOVE_PROMOTIONS_FROM_CHANNEL, {
+                input: {
+                    channelId: secondChannel.id,
+                    promotionIds: [promotion.id],
+                },
+            });
+
+            expect(removePromotionsFromChannel).toEqual([{ id: promotion.id, name: promotion.name }]);
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { promotion: result } = await adminClient.query<GetPromotion.Query, GetPromotion.Variables>(
+                GET_PROMOTION,
+                {
+                    id: promotion.id,
+                },
+            );
+            expect(result).toBeNull();
+
+            const { promotions } = await adminClient.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
+            expect(promotions.totalItems).toBe(0);
+        });
+    });
+
     describe('deletion', () => {
         let allPromotions: GetPromotionList.Items[];
         let promotionToDelete: GetPromotionList.Items;
 
         beforeAll(async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
             const result = await adminClient.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
             allPromotions = result.promotions.items;
         });

+ 22 - 0
packages/core/src/api/resolvers/admin/promotion.resolver.ts

@@ -2,8 +2,10 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     CreatePromotionResult,
     DeletionResponse,
+    MutationAssignPromotionsToChannelArgs,
     MutationCreatePromotionArgs,
     MutationDeletePromotionArgs,
+    MutationRemovePromotionsFromChannelArgs,
     MutationUpdatePromotionArgs,
     Permission,
     QueryPromotionArgs,
@@ -110,6 +112,26 @@ export class PromotionResolver {
         return this.promotionService.softDeletePromotion(ctx, args.id);
     }
 
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdatePromotion)
+    assignPromotionsToChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationAssignPromotionsToChannelArgs,
+    ): Promise<Promotion[]> {
+        return this.promotionService.assignPromotionsToChannel(ctx, args.input);
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdatePromotion)
+    removePromotionsFromChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationRemovePromotionsFromChannelArgs,
+    ): Promise<Promotion[]> {
+        return this.promotionService.removePromotionsFromChannel(ctx, args.input);
+    }
+
     /**
      * Encodes any entity IDs used in the filter arguments.
      */

+ 14 - 0
packages/core/src/api/schema/admin-api/promotion.api.graphql

@@ -9,6 +9,10 @@ type Mutation {
     createPromotion(input: CreatePromotionInput!): CreatePromotionResult!
     updatePromotion(input: UpdatePromotionInput!): UpdatePromotionResult!
     deletePromotion(id: ID!): DeletionResponse!
+    "Assigns Promotions to the specified Channel"
+    assignPromotionsToChannel(input: AssignPromotionsToChannelInput!): [Promotion!]!
+    "Removes Promotions from the specified Channel"
+    removePromotionsFromChannel(input: RemovePromotionsFromChannelInput!): [Promotion!]!
 }
 
 # generated by generateListOptions function
@@ -37,6 +41,16 @@ input UpdatePromotionInput {
     actions: [ConfigurableOperationInput!]
 }
 
+input AssignPromotionsToChannelInput {
+    promotionIds: [ID!]!
+    channelId: ID!
+}
+
+input RemovePromotionsFromChannelInput {
+    promotionIds: [ID!]!
+    channelId: ID!
+}
+
 "Returned if a PromotionCondition has neither a couponCode nor any conditions set"
 type MissingConditionsError implements ErrorResult {
     errorCode: ErrorCode!

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -38,6 +38,7 @@
     "product-id-slug-mismatch": "The provided id and slug refer to different Products",
     "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",
     "product-variant-options-combination-already-exists": "A ProductVariant already exists with the options: {optionNames}",
+    "promotion-channels-can-only-be-changed-from-default-channel": "Promotions channels may only be changed from the Default Channel",
     "stockonhand-cannot-be-negative": "stockOnHand cannot be a negative value",
     "unauthorized": "The credentials did not match. Please check and try again"
   },

+ 41 - 1
packages/core/src/service/services/promotion.service.ts

@@ -1,11 +1,13 @@
 import { Injectable } from '@nestjs/common';
 import { ApplyCouponCodeResult } from '@vendure/common/lib/generated-shop-types';
 import {
+    AssignPromotionsToChannelInput,
     ConfigurableOperationDefinition,
     CreatePromotionInput,
     CreatePromotionResult,
     DeletionResponse,
     DeletionResult,
+    RemovePromotionsFromChannelInput,
     UpdatePromotionInput,
     UpdatePromotionResult,
 } from '@vendure/common/lib/generated-types';
@@ -15,6 +17,7 @@ import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
 import { ErrorResultUnion, JustErrorResults } from '../../common/error/error-result';
+import { IllegalOperationError } from '../../common/error/errors';
 import { MissingConditionsError } from '../../common/error/generated-graphql-admin-errors';
 import {
     CouponCodeExpiredError,
@@ -23,7 +26,7 @@ import {
 } from '../../common/error/generated-graphql-shop-errors';
 import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { ListQueryOptions } from '../../common/types/common-types';
-import { assertFound } from '../../common/utils';
+import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { PromotionAction } from '../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../config/promotion/promotion-condition';
@@ -158,6 +161,43 @@ export class PromotionService {
         };
     }
 
+    async assignPromotionsToChannel(
+        ctx: RequestContext,
+        input: AssignPromotionsToChannelInput,
+    ): Promise<Promotion[]> {
+        if (!idsAreEqual(ctx.channelId, this.channelService.getDefaultChannel().id)) {
+            throw new IllegalOperationError(`promotion-channels-can-only-be-changed-from-default-channel`);
+        }
+        const promotions = await this.connection.findByIdsInChannel(
+            ctx,
+            Promotion,
+            input.promotionIds,
+            ctx.channelId,
+            {},
+        );
+        for (const promotion of promotions) {
+            await this.channelService.assignToChannels(ctx, Promotion, promotion.id, [input.channelId]);
+        }
+        return promotions;
+    }
+
+    async removePromotionsFromChannel(ctx: RequestContext, input: RemovePromotionsFromChannelInput) {
+        if (!idsAreEqual(ctx.channelId, this.channelService.getDefaultChannel().id)) {
+            throw new IllegalOperationError(`promotion-channels-can-only-be-changed-from-default-channel`);
+        }
+        const promotions = await this.connection.findByIdsInChannel(
+            ctx,
+            Promotion,
+            input.promotionIds,
+            ctx.channelId,
+            {},
+        );
+        for (const promotion of promotions) {
+            await this.channelService.removeFromChannels(ctx, Promotion, promotion.id, [input.channelId]);
+        }
+        return promotions;
+    }
+
     async validateCouponCode(
         ctx: RequestContext,
         couponCode: string,

Fișier diff suprimat deoarece este prea mare
+ 513 - 645
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-admin.json


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff