Browse Source

feat(server): Add orderItemsLimit option

Limits the max items allowed in a single order. Closes #70
Michael Bromley 6 years ago
parent
commit
e98e90add2

+ 23 - 0
server/e2e/shop-order.e2e-spec.ts

@@ -39,6 +39,9 @@ describe('Shop orders', () => {
                 paymentOptions: {
                     paymentMethodHandlers: [testPaymentMethod, testFailingPaymentMethod],
                 },
+                orderOptions: {
+                    orderItemsLimit: 99,
+                },
             },
         );
         await shopClient.init();
@@ -131,6 +134,16 @@ describe('Shop orders', () => {
             expect(result.addItemToOrder.lines[0].quantity).toBe(3);
         });
 
+        it(
+            'addItemToOrder errors when going beyond orderItemsLimit',
+            assertThrowsWithMessage(async () => {
+                await shopClient.query(ADD_ITEM_TO_ORDER, {
+                    productVariantId: 'T_1',
+                    quantity: 100,
+                });
+            }, 'Cannot add items. An order may consist of a maximum of 99 items'),
+        );
+
         it('adjustItemQuantity adjusts the quantity', async () => {
             const result = await shopClient.query(ADJUST_ITEM_QUENTITY, {
                 orderItemId: firstOrderItemId,
@@ -141,6 +154,16 @@ describe('Shop orders', () => {
             expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
         });
 
+        it(
+            'adjustItemQuantity errors when going beyond orderItemsLimit',
+            assertThrowsWithMessage(async () => {
+                await shopClient.query(ADJUST_ITEM_QUENTITY, {
+                    orderItemId: firstOrderItemId,
+                    quantity: 100,
+                });
+            }, 'Cannot add items. An order may consist of a maximum of 99 items'),
+        );
+
         it(
             'adjustItemQuantity errors with a negative quantity',
             assertThrowsWithMessage(

+ 6 - 0
server/src/common/error/errors.ts

@@ -80,3 +80,9 @@ export class NotVerifiedError extends I18nError {
         super('error.email-address-not-verified', {}, 'NOT_VERIFIED');
     }
 }
+
+export class OrderItemsLimitError extends I18nError {
+    constructor(maxItems: number) {
+        super('error.order-items-limit-exceeded', { maxItems }, 'ORDER_ITEMS_LIMIT_EXCEEDED');
+    }
+}

+ 2 - 2
server/src/config/config.service.ts

@@ -89,8 +89,8 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.shippingOptions;
     }
 
-    get orderOptions(): OrderOptions {
-        return this.activeConfig.orderOptions;
+    get orderOptions(): Required<OrderOptions> {
+        return this.activeConfig.orderOptions as Required<OrderOptions>;
     }
 
     get paymentOptions(): PaymentOptions {

+ 1 - 0
server/src/config/default-config.ts

@@ -62,6 +62,7 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         shippingCalculators: [defaultShippingCalculator],
     },
     orderOptions: {
+        orderItemsLimit: 999,
         mergeStrategy: new MergeOrdersStrategy(),
         checkoutMergeStrategy: new UseGuestStrategy(),
         process: {},

+ 2 - 0
server/src/config/order-merge-strategy/merge-orders-strategy.ts

@@ -7,6 +7,8 @@ import { OrderMergeStrategy } from './order-merge-strategy';
  * @description
  * Merges both Orders. If the guest order contains items which are already in the
  * existing Order, the guest Order quantity will replace that of the existing Order.
+ *
+ * @docsCategory orders
  */
 export class MergeOrdersStrategy implements OrderMergeStrategy {
     merge(guestOrder: Order, existingOrder: Order): OrderLine[] {

+ 6 - 0
server/src/config/order-merge-strategy/use-existing-strategy.ts

@@ -3,6 +3,12 @@ import { Order } from '../../entity/order/order.entity';
 
 import { OrderMergeStrategy } from './order-merge-strategy';
 
+/**
+ * @description
+ * The guest order is discarded and the existing order is used as the active order.
+ *
+ * @docsCategory orders
+ */
 export class UseExistingStrategy implements OrderMergeStrategy {
     merge(guestOrder: Order, existingOrder: Order): OrderLine[] {
         return existingOrder.lines.slice();

+ 6 - 0
server/src/config/order-merge-strategy/use-guest-if-existing-empty-strategy.ts

@@ -3,6 +3,12 @@ import { Order } from '../../entity/order/order.entity';
 
 import { OrderMergeStrategy } from './order-merge-strategy';
 
+/**
+ * @description
+ * If the existing order is empty, then the guest order is used. Otherwise the existing order is used.
+ *
+ * @docsCategory orders
+ */
 export class UseGuestIfExistingEmptyStrategy implements OrderMergeStrategy {
     merge(guestOrder: Order, existingOrder: Order): OrderLine[] {
         return existingOrder.lines.length ? existingOrder.lines.slice() : guestOrder.lines.slice();

+ 6 - 0
server/src/config/order-merge-strategy/use-guest-strategy.ts

@@ -3,6 +3,12 @@ import { Order } from '../../entity/order/order.entity';
 
 import { OrderMergeStrategy } from './order-merge-strategy';
 
+/**
+ * @description
+ * Any existing order is discarded and the guest order is set as the active order.
+ *
+ * @docsCategory orders
+ */
 export class UseGuestStrategy implements OrderMergeStrategy {
     merge(guestOrder: Order, existingOrder: Order): OrderLine[] {
         return guestOrder.lines.slice();

+ 24 - 3
server/src/config/vendure-config.ts

@@ -109,6 +109,9 @@ export interface AuthOptions {
 }
 
 /**
+ * @description
+ * Defines custom states and transition logic for the order process state machine.
+ *
  * @docsCategory orders
  */
 export interface OrderProcessOptions<T extends string> {
@@ -143,25 +146,43 @@ export interface OrderProcessOptions<T extends string> {
 
 /**
  * @docsCategory orders
+ *
+ * @docsWeight 0
  */
 export interface OrderOptions {
+    /**
+     * @description
+     * The maximum number of individual items allowed in a single order. This option exists
+     * to prevent excessive resource usage when dealing with very large orders. For example,
+     * if an order contains a million items, then any operations on that order (modifying a quantity,
+     * adding or removing an item) will require Vendure to loop through all million items
+     * to perform price calculations against active promotions and taxes. This can have a significant
+     * performance impact for very large values.
+     *
+     * @default 999
+     */
+    orderItemsLimit?: number;
     /**
      * @description
      * Defines custom states and transition logic for the order process state machine.
      */
-    process: OrderProcessOptions<string>;
+    process?: OrderProcessOptions<string>;
     /**
      * @description
      * Defines the strategy used to merge a guest Order and an existing Order when
      * signing in.
+     *
+     * @default MergeOrdersStrategy
      */
-    mergeStrategy: OrderMergeStrategy;
+    mergeStrategy?: OrderMergeStrategy;
     /**
      * @description
      * Defines the strategy used to merge a guest Order and an existing Order when
      * signing in as part of the checkout flow.
+     *
+     * @default UseGuestStrategy
      */
-    checkoutMergeStrategy: OrderMergeStrategy;
+    checkoutMergeStrategy?: OrderMergeStrategy;
 }
 
 /**

+ 1 - 0
server/src/i18n/messages/en.json

@@ -18,6 +18,7 @@
     "order-contents-may-only-be-modified-in-addingitems-state": "Order contents may only be modified when in the \"AddingItems\" state",
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem",
+    "order-items-limit-exceeded": "Cannot add items. An order may consist of a maximum of { maxItems } items",
     "payment-may-only-be-added-in-arrangingpayment-state": "A Payment may only be added when Order is in \"ArrangingPayment\" state",
     "password-reset-token-has-expired": "Password reset token has expired.",
     "password-reset-token-not-recognized": "Password reset token not recognized",

+ 22 - 1
server/src/service/services/order.service.ts

@@ -5,10 +5,16 @@ import { PaymentInput } from '../../../../shared/generated-shop-types';
 import { CreateAddressInput, ShippingMethodQuote } from '../../../../shared/generated-types';
 import { ID, PaginatedList } from '../../../../shared/shared-types';
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError, IllegalOperationError, UserInputError } from '../../common/error/errors';
+import {
+    EntityNotFoundError,
+    IllegalOperationError,
+    OrderItemsLimitError,
+    UserInputError,
+} from '../../common/error/errors';
 import { generatePublicId } from '../../common/generate-public-id';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { idsAreEqual } from '../../common/utils';
+import { ConfigService } from '../../config/config.service';
 import { Customer } from '../../entity/customer/customer.entity';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
@@ -33,6 +39,7 @@ import { ProductVariantService } from './product-variant.service';
 export class OrderService {
     constructor(
         @InjectConnection() private connection: Connection,
+        private configService: ConfigService,
         private productVariantService: ProductVariantService,
         private customerService: CustomerService,
         private countryService: CountryService,
@@ -168,6 +175,7 @@ export class OrderService {
         this.assertQuantityIsPositive(quantity);
         const order = await this.getOrderOrThrow(ctx, orderId);
         this.assertAddingItemsState(order);
+        this.assertNotOverOrderItemsLimit(order, quantity);
         const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId);
         let orderLine = order.lines.find(line => idsAreEqual(line.productVariant.id, productVariantId));
 
@@ -191,6 +199,7 @@ export class OrderService {
         this.assertAddingItemsState(order);
         const orderLine = this.getOrderLineOrThrow(order, orderLineId);
         const currentQuantity = orderLine.quantity;
+        this.assertNotOverOrderItemsLimit(order, quantity - currentQuantity);
         if (currentQuantity < quantity) {
             if (!orderLine.items) {
                 orderLine.items = [];
@@ -379,6 +388,18 @@ export class OrderService {
         }
     }
 
+    /**
+     * Throws if adding the given quantity would take the total order items over the
+     * maximum limit specified in the config.
+     */
+    private assertNotOverOrderItemsLimit(order: Order, quantityToAdd: number) {
+        const currentItemsCount = order.lines.reduce((count, line) => count + line.quantity, 0);
+        const { orderItemsLimit } = this.configService.orderOptions;
+        if (orderItemsLimit < currentItemsCount + quantityToAdd) {
+            throw new OrderItemsLimitError(orderItemsLimit);
+        }
+    }
+
     /**
      * Applies promotions, taxes and shipping to the Order.
      */