Browse Source

refactor(core): Improve typings of PromotionAction dependencies

Michael Bromley 4 years ago
parent
commit
d4d47bad88

+ 10 - 5
packages/core/src/config/promotion/actions/buy-x-get-y-free-action.ts

@@ -3,17 +3,22 @@ import { ID } from '@vendure/common/lib/shared-types';
 
 import { idsAreEqual } from '../../../common/utils';
 import { OrderItem } from '../../../entity';
+import { buyXGetYFreeCondition } from '../conditions/buy-x-get-y-free-condition';
 import { PromotionItemAction } from '../promotion-action';
 
 export const buyXGetYFreeAction = new PromotionItemAction({
     code: 'buy_x_get_y_free',
-    description: [{ languageCode: LanguageCode.en, value: 'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free' }],
+    description: [
+        {
+            languageCode: LanguageCode.en,
+            value:
+                'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free',
+        },
+    ],
     args: {},
-    conditions: {
-        'buy_x_get_y_free': { required: true },
-    },
+    conditions: [buyXGetYFreeCondition],
     execute(ctx, orderItem, orderLine, args, state) {
-        const freeItemIds  = state.buy_x_get_y_free.freeItemIds as ID[];
+        const freeItemIds = state.buy_x_get_y_free.freeItemIds;
         if (idsContainsItem(freeItemIds, orderItem)) {
             const unitPrice = ctx.channel.pricesIncludeTax ? orderLine.unitPriceWithTax : orderLine.unitPrice;
             return -unitPrice;

+ 15 - 8
packages/core/src/config/promotion/conditions/buy-x-get-y-free-condition.ts

@@ -6,7 +6,11 @@ import { PromotionCondition } from '../promotion-condition';
 export const buyXGetYFreeCondition = new PromotionCondition({
     code: 'buy_x_get_y_free',
     description: [
-        { languageCode: LanguageCode.en, value: 'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free' },
+        {
+            languageCode: LanguageCode.en,
+            value:
+                'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free',
+        },
     ],
     args: {
         amountX: {
@@ -46,13 +50,16 @@ export const buyXGetYFreeCondition = new PromotionCondition({
         }
         const quantity = Math.floor(matches / args.amountX);
         if (!quantity || !freeItemCandidates.length) return false;
-        const freeItemIds = freeItemCandidates.sort((a, b) => {
-            const unitPriceA = ctx.channel.pricesIncludeTax ? a.unitPriceWithTax : a.unitPrice;
-            const unitPriceB = ctx.channel.pricesIncludeTax ? b.unitPriceWithTax : b.unitPrice;
-            if (unitPriceA < unitPriceB) return -1;
-            if (unitPriceA > unitPriceB) return 1;
-            return 0;
-        }).map(({ id }) => id).slice(0, quantity * args.amountY);
+        const freeItemIds = freeItemCandidates
+            .sort((a, b) => {
+                const unitPriceA = ctx.channel.pricesIncludeTax ? a.unitPriceWithTax : a.unitPrice;
+                const unitPriceB = ctx.channel.pricesIncludeTax ? b.unitPriceWithTax : b.unitPrice;
+                if (unitPriceA < unitPriceB) return -1;
+                if (unitPriceA > unitPriceB) return 1;
+                return 0;
+            })
+            .map(({ id }) => id)
+            .slice(0, quantity * args.amountY);
         return { freeItemIds };
     },
 });

+ 138 - 32
packages/core/src/config/promotion/promotion-action.ts

@@ -14,16 +14,53 @@ import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { Order } from '../../entity/order/order.entity';
 import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
 
-import { PromotionConditionState } from './promotion-condition';
+import { PromotionCondition } from './promotion-condition';
 
+/**
+ * Unwrap a promise type
+ */
+type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
 
-export type ConditionConfig = {
-    [name: string]: { required?: boolean };
-}
+/**
+ * Extract the (non-false) return value of the PromotionCondition "check" function.
+ */
+type ConditionCheckReturnType<T extends PromotionCondition<any>> = Exclude<
+    Awaited<ReturnType<T['check']>>,
+    false
+>;
 
-export type ConditionalState<U extends ConditionConfig> = {
-    [K in keyof U]: U[K]['required'] extends true ? PromotionConditionState : PromotionConditionState | undefined;
-}
+/**
+ * Converts an array of PromotionCondition types into a tuple, thus preserving the
+ * distinct type of each condition in the array.
+ */
+export type ConditionTuple<C extends Array<PromotionCondition<any>>> = [...C];
+
+/**
+ * Converts an array of PromotionConditions into a tuple of the type:
+ * [<condition code>, <check function return value>]
+ */
+type CodesStateTuple<T extends ConditionTuple<Array<PromotionCondition<any>>>> = {
+    [K in keyof T]: T[K] extends PromotionCondition<any>
+        ? [T[K]['code'], ConditionCheckReturnType<T[K]>]
+        : never;
+};
+
+/**
+ * Convert a tuple into a union
+ * [[string, number], [number, boolean]] => [string, number] | [number, boolean]
+ */
+type TupleToUnion<T extends any[]> = T[number];
+
+/**
+ * Converts an array of PromotionConditions into an object of the type:
+ * {
+ *     [PromotionCondition.code]: ReturnType<PromotionCondition.check()>
+ * }
+ */
+export type ConditionState<
+    U extends Array<PromotionCondition<any>>,
+    T extends [string, any] = TupleToUnion<CodesStateTuple<ConditionTuple<U>>>
+> = { [key in T[0]]: Extract<T, [key, any]>[1] };
 
 /**
  * @description
@@ -33,12 +70,12 @@ export type ConditionalState<U extends ConditionConfig> = {
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export type ExecutePromotionItemActionFn<T extends ConfigArgs, U extends ConditionConfig> = (
+export type ExecutePromotionItemActionFn<T extends ConfigArgs, U extends Array<PromotionCondition<any>>> = (
     ctx: RequestContext,
     orderItem: OrderItem,
     orderLine: OrderLine,
     args: ConfigArgValues<T>,
-    state: ConditionalState<U>,
+    state: ConditionState<U>,
 ) => number | Promise<number>;
 
 /**
@@ -49,11 +86,11 @@ export type ExecutePromotionItemActionFn<T extends ConfigArgs, U extends Conditi
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export type ExecutePromotionOrderActionFn<T extends ConfigArgs, U extends ConditionConfig> = (
+export type ExecutePromotionOrderActionFn<T extends ConfigArgs, U extends Array<PromotionCondition<any>>> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
-    state: ConditionalState<U>,
+    state: ConditionState<U>,
 ) => number | Promise<number>;
 
 /**
@@ -64,19 +101,47 @@ export type ExecutePromotionOrderActionFn<T extends ConfigArgs, U extends Condit
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export type ExecutePromotionShippingActionFn<T extends ConfigArgs, U extends ConditionConfig> = (
+export type ExecutePromotionShippingActionFn<
+    T extends ConfigArgs,
+    U extends Array<PromotionCondition<any>>
+> = (
     ctx: RequestContext,
     shippingLine: ShippingLine,
     order: Order,
     args: ConfigArgValues<T>,
-    state: ConditionalState<U>,
+    state: ConditionState<U>,
 ) => number | Promise<number>;
 
-
-export interface PromotionActionConfig<T extends ConfigArgs, U extends ConditionConfig>
-    extends ConfigurableOperationDefOptions<T> {
+/**
+ * @description
+ * Configuration for all types of {@link PromotionAction}.
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ */
+export interface PromotionActionConfig<
+    T extends ConfigArgs,
+    U extends Array<PromotionCondition<any>> | undefined
+> extends ConfigurableOperationDefOptions<T> {
+    /**
+     * @description
+     * Used to determine the order of application of multiple Promotions
+     * on the same Order. See the {@link Promotion} `priorityScore` field for
+     * more information.
+     *
+     * @default 0
+     */
     priorityValue?: number;
-    conditions?: U;
+    /**
+     * @description
+     * Allows PromotionActions to define one or more PromotionConditions as dependencies. Having a PromotionCondition
+     * as a dependency has the following consequences:
+     * 1. A Promotion using this PromotionAction is only valid if it also contains all PromotionConditions
+     * on which it depends.
+     * 2. The `execute()` function will receive a statically-typed `state` argument which will contain
+     * the return values of the PromotionConditions' `check()` function.
+     */
+    conditions?: U extends undefined ? undefined : ConditionTuple<Exclude<U, undefined>>;
 }
 
 /**
@@ -86,7 +151,7 @@ export interface PromotionActionConfig<T extends ConfigArgs, U extends Condition
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export interface PromotionItemActionConfig<T extends ConfigArgs, U extends ConditionConfig>
+export interface PromotionItemActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
     extends PromotionActionConfig<T, U> {
     /**
      * @description
@@ -101,7 +166,7 @@ export interface PromotionItemActionConfig<T extends ConfigArgs, U extends Condi
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export interface PromotionOrderActionConfig<T extends ConfigArgs, U extends ConditionConfig>
+export interface PromotionOrderActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
     extends PromotionActionConfig<T, U> {
     /**
      * @description
@@ -116,7 +181,7 @@ export interface PromotionOrderActionConfig<T extends ConfigArgs, U extends Cond
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export interface PromotionShippingActionConfig<T extends ConfigArgs, U extends ConditionConfig>
+export interface PromotionShippingActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
     extends PromotionActionConfig<T, U> {
     /**
      * @description
@@ -127,13 +192,17 @@ export interface PromotionShippingActionConfig<T extends ConfigArgs, U extends C
 
 /**
  * @description
- * An abstract class which is extended by {@link PromotionItemAction} and {@link PromotionOrderAction}.
+ * An abstract class which is extended by {@link PromotionItemAction}, {@link PromotionOrderAction},
+ * and {@link PromotionShippingAction}.
  *
  * @docsCategory promotions
  * @docsPage promotion-action
  * @docsWeight 0
  */
-export abstract class PromotionAction<T extends ConfigArgs = {}, U extends ConditionConfig = {}> extends ConfigurableOperationDef<T> {
+export abstract class PromotionAction<
+    T extends ConfigArgs = {},
+    U extends PromotionCondition[] | undefined = any
+> extends ConfigurableOperationDef<T> {
     /**
      * @description
      * Used to determine the order of application of multiple Promotions
@@ -173,8 +242,10 @@ export abstract class PromotionAction<T extends ConfigArgs = {}, U extends Condi
  * @docsPage promotion-action
  * @docsWeight 1
  */
-export class PromotionItemAction<T extends ConfigArgs = ConfigArgs, U extends ConditionConfig = {}>
-    extends PromotionAction<T, U> {
+export class PromotionItemAction<
+    T extends ConfigArgs = ConfigArgs,
+    U extends Array<PromotionCondition<any>> = []
+> extends PromotionAction<T, U> {
     private readonly executeFn: ExecutePromotionItemActionFn<T, U>;
     constructor(config: PromotionItemActionConfig<T, U>) {
         super(config);
@@ -182,9 +253,26 @@ export class PromotionItemAction<T extends ConfigArgs = ConfigArgs, U extends Co
     }
 
     /** @internal */
-    execute(ctx: RequestContext, orderItem: OrderItem, orderLine: OrderLine, args: ConfigArg[], state: PromotionState) {
-        const actionState = this.conditions ? pick(state, Object.keys(this.conditions)) : {};
-        return this.executeFn(ctx, orderItem, orderLine, this.argsArrayToHash(args), actionState as ConditionalState<U>);
+    execute(
+        ctx: RequestContext,
+        orderItem: OrderItem,
+        orderLine: OrderLine,
+        args: ConfigArg[],
+        state: PromotionState,
+    ) {
+        const actionState = this.conditions
+            ? pick(
+                  state,
+                  this.conditions.map(c => c.code),
+              )
+            : {};
+        return this.executeFn(
+            ctx,
+            orderItem,
+            orderLine,
+            this.argsArrayToHash(args),
+            actionState as ConditionState<U>,
+        );
     }
 }
 
@@ -209,7 +297,10 @@ export class PromotionItemAction<T extends ConfigArgs = ConfigArgs, U extends Co
  * @docsPage promotion-action
  * @docsWeight 2
  */
-export class PromotionOrderAction<T extends ConfigArgs = ConfigArgs, U extends ConditionConfig = {}> extends PromotionAction<T, U> {
+export class PromotionOrderAction<
+    T extends ConfigArgs = ConfigArgs,
+    U extends PromotionCondition[] = []
+> extends PromotionAction<T, U> {
     private readonly executeFn: ExecutePromotionOrderActionFn<T, U>;
     constructor(config: PromotionOrderActionConfig<T, U>) {
         super(config);
@@ -219,7 +310,7 @@ export class PromotionOrderAction<T extends ConfigArgs = ConfigArgs, U extends C
     /** @internal */
     execute(ctx: RequestContext, order: Order, args: ConfigArg[], state: PromotionState) {
         const actionState = this.conditions ? pick(state, Object.keys(this.conditions)) : {};
-        return this.executeFn(ctx, order, this.argsArrayToHash(args), actionState as ConditionalState<U>);
+        return this.executeFn(ctx, order, this.argsArrayToHash(args), actionState as ConditionState<U>);
     }
 }
 
@@ -231,7 +322,10 @@ export class PromotionOrderAction<T extends ConfigArgs = ConfigArgs, U extends C
  * @docsPage promotion-action
  * @docsWeight 3
  */
-export class PromotionShippingAction<T extends ConfigArgs = ConfigArgs, U extends ConditionConfig = {}> extends PromotionAction<T, U> {
+export class PromotionShippingAction<
+    T extends ConfigArgs = ConfigArgs,
+    U extends PromotionCondition[] = []
+> extends PromotionAction<T, U> {
     private readonly executeFn: ExecutePromotionShippingActionFn<T, U>;
     constructor(config: PromotionShippingActionConfig<T, U>) {
         super(config);
@@ -239,8 +333,20 @@ export class PromotionShippingAction<T extends ConfigArgs = ConfigArgs, U extend
     }
 
     /** @internal */
-    execute(ctx: RequestContext, shippingLine: ShippingLine, order: Order, args: ConfigArg[], state: PromotionState) {
+    execute(
+        ctx: RequestContext,
+        shippingLine: ShippingLine,
+        order: Order,
+        args: ConfigArg[],
+        state: PromotionState,
+    ) {
         const actionState = this.conditions ? pick(state, Object.keys(this.conditions)) : {};
-        return this.executeFn(ctx, shippingLine, order, this.argsArrayToHash(args), actionState as ConditionalState<U>);
+        return this.executeFn(
+            ctx,
+            shippingLine,
+            order,
+            this.argsArrayToHash(args),
+            actionState as ConditionState<U>,
+        );
     }
 }

+ 35 - 10
packages/core/src/config/promotion/promotion-condition.ts

@@ -9,20 +9,29 @@ import {
 } from '../../common/configurable-operation';
 import { Order } from '../../entity/order/order.entity';
 
+export type PromotionConditionState = Record<string, unknown>;
+
 export type CheckPromotionConditionResult = boolean | PromotionConditionState;
 
 /**
  * @description
  * A function which checks whether or not a given {@link Order} satisfies the {@link PromotionCondition}.
  *
+ * The function should return either a `boolean` or and plain object type:
+ *
+ * * `false`: The condition is not satisfied - do not apply PromotionActions
+ * * `true`: The condition is satisfied, apply PromotionActions
+ * * `{ [key: string]: any; }`: The condition is satisfied, apply PromotionActions
+ * _and_ pass this object into the PromotionAction's `state` argument.
+ *
  * @docsCategory promotions
  * @docsPage promotion-condition
  */
-export type CheckPromotionConditionFn<T extends ConfigArgs> = (
+export type CheckPromotionConditionFn<T extends ConfigArgs, R extends CheckPromotionConditionResult> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
-) => CheckPromotionConditionResult | Promise<CheckPromotionConditionResult>;
+) => R | Promise<R>;
 
 /**
  * @description
@@ -32,13 +41,16 @@ export type CheckPromotionConditionFn<T extends ConfigArgs> = (
  * @docsPage promotion-condition
  * @docsWeight 1
  */
-export interface PromotionConditionConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
-    check: CheckPromotionConditionFn<T>;
+export interface PromotionConditionConfig<
+    T extends ConfigArgs,
+    C extends string,
+    R extends CheckPromotionConditionResult
+> extends ConfigurableOperationDefOptions<T> {
+    code: C;
+    check: CheckPromotionConditionFn<T, R>;
     priorityValue?: number;
 }
 
-export type PromotionConditionState = Record<string, unknown>;
-
 /**
  * @description
  * PromotionConditions are used to create {@link Promotion}s. The purpose of a PromotionCondition
@@ -49,7 +61,11 @@ export type PromotionConditionState = Record<string, unknown>;
  * @docsPage promotion-condition
  * @docsWeight 0
  */
-export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends ConfigurableOperationDef<T> {
+export class PromotionCondition<
+    T extends ConfigArgs = ConfigArgs,
+    C extends string = string,
+    R extends CheckPromotionConditionResult = any
+> extends ConfigurableOperationDef<T> {
     /**
      * @description
      * Used to determine the order of application of multiple Promotions
@@ -59,15 +75,24 @@ export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends Confi
      * @default 0
      */
     readonly priorityValue: number;
-    private readonly checkFn: CheckPromotionConditionFn<T>;
+    private readonly checkFn: CheckPromotionConditionFn<T, R>;
 
-    constructor(config: PromotionConditionConfig<T>) {
+    get code(): C {
+        return super.code as C;
+    }
+
+    constructor(config: PromotionConditionConfig<T, C, R>) {
         super(config);
         this.checkFn = config.check;
         this.priorityValue = config.priorityValue || 0;
     }
 
-    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<CheckPromotionConditionResult> {
+    /**
+     * @description
+     * This is the function which contains the conditional logic to decide whether
+     * a Promotion should apply to an Order. See {@link CheckPromotionConditionFn}.
+     */
+    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<R> {
         return this.checkFn(ctx, order, this.argsArrayToHash(args));
     }
 }

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

@@ -10,6 +10,7 @@
     "cannot-transition-fulfillment-from-to": "Cannot transition Fulfillment from \"{ fromState }\" to \"{ toState }\"",
     "collection-id-or-slug-must-be-provided": "Either the Collection id or slug must be provided",
     "collection-id-slug-mismatch": "The provided id and slug refer to different Collections",
+    "conditions-required-for-action": "The PromotionAction '{ action }' requires the following conditions: { conditions }",
     "configurable-argument-is-required": "The argument '{ name }' is required, but the value is [{ value }]",
     "country-code-not-valid": "The countryCode \"{ countryCode }\" was not recognized",
     "customer-does-not-belong-to-customer-group": "Customer does not belong to this CustomerGroup",

+ 18 - 11
packages/core/src/service/services/promotion.service.ts

@@ -27,7 +27,7 @@ import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
-import { ConditionConfig, PromotionAction } from '../../config/promotion/promotion-action';
+import { PromotionAction } from '../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../config/promotion/promotion-condition';
 import { Order } from '../../entity/order/order.entity';
 import { Promotion } from '../../entity/promotion/promotion.entity';
@@ -103,7 +103,9 @@ export class PromotionService {
         ctx: RequestContext,
         input: CreatePromotionInput,
     ): Promise<ErrorResultUnion<CreatePromotionResult, Promotion>> {
-        const conditions = input.conditions.map(c => this.configArgService.parseInput('PromotionCondition', c));
+        const conditions = input.conditions.map(c =>
+            this.configArgService.parseInput('PromotionCondition', c),
+        );
         const actions = input.actions.map(a => this.configArgService.parseInput('PromotionAction', a));
         this.validateRequiredConditions(conditions, actions);
         const promotion = new Promotion({
@@ -234,20 +236,25 @@ export class PromotionService {
         });
     }
 
-    private validateRequiredConditions(conditions: ConfigurableOperation[], actions: ConfigurableOperation[]) {
-        const conditionCodes: Record<string, string> = conditions.reduce((codeMap, { code }) => ({ ...codeMap, [code]: code }), {});
+    private validateRequiredConditions(
+        conditions: ConfigurableOperation[],
+        actions: ConfigurableOperation[],
+    ) {
+        const conditionCodes: Record<string, string> = conditions.reduce(
+            (codeMap, { code }) => ({ ...codeMap, [code]: code }),
+            {},
+        );
         for (const { code: actionCode } of actions) {
-            const { conditions: conditionConfig } =
-                this.configArgService.getByCode('PromotionAction', actionCode) as { conditions?: ConditionConfig };
-            if (!conditionConfig) {
+            const actionDef = this.configArgService.getByCode('PromotionAction', actionCode);
+            const actionDependencies: PromotionCondition[] = actionDef.conditions || [];
+            if (!actionDependencies || actionDependencies.length === 0) {
                 continue;
             }
-            const missingConditions = Object.keys(conditionConfig)
-                .filter((conditionCode) => conditionConfig[conditionCode].required && !conditionCodes[conditionCode]);
+            const missingConditions = actionDependencies.filter(condition => !conditionCodes[condition.code]);
             if (missingConditions.length) {
                 throw new UserInputError('error.conditions-required-for-action', {
-                    name: actionCode,
-                    value: String(missingConditions),
+                    action: actionCode,
+                    conditions: missingConditions.map(c => c.code).join(', '),
                 });
             }
         }