From a45b635281d668e655aa5ddfd6cda5644d102b3d Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Mon, 7 Oct 2019 11:30:12 -0700 Subject: [PATCH 1/9] refactor: remove unused updateShipmentStatus function Signed-off-by: Erik Kieckhafer --- .../server/util/updateShipmentStatus.js | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 imports/plugins/core/orders/server/util/updateShipmentStatus.js diff --git a/imports/plugins/core/orders/server/util/updateShipmentStatus.js b/imports/plugins/core/orders/server/util/updateShipmentStatus.js deleted file mode 100644 index 5660826cb84..00000000000 --- a/imports/plugins/core/orders/server/util/updateShipmentStatus.js +++ /dev/null @@ -1,59 +0,0 @@ -import { Meteor } from "meteor/meteor"; -import { Orders } from "/lib/collections"; -import appEvents from "/imports/node-app/core/util/appEvents"; -import Reaction from "/imports/plugins/core/core/server/Reaction"; -import ReactionError from "@reactioncommerce/reaction-error"; - -/** - * @summary Expected to be called from a Meteor method - * @param {Object} input Arguments - * @param {String} input.fulfillmentGroupId ID of fulfillment group to update status for - * @param {String[]} input.fulfillmentGroupItemIds Array of item IDs in this group - * @param {Object} order The order that contains the group - * @param {String} status The new status for the group - * @returns {Number} Orders.update result - */ -export default function updateShipmentStatus(input) { - const { - fulfillmentGroupId, - fulfillmentGroupItemIds, - order, - status - } = input; - - const authUserId = Reaction.getUserId(); - if (!Reaction.hasPermission("orders", authUserId, order.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - - const result = Meteor.call("workflow/pushItemWorkflow", `coreOrderItemWorkflow/${status}`, order, fulfillmentGroupItemIds); - if (result !== 1) { - throw new ReactionError("server-error", "Unable to update order"); - } - - const newStatus = `coreOrderWorkflow/${status}`; - - const updateResult = Orders.update( - { - "_id": order._id, - "shipping._id": fulfillmentGroupId - }, - { - $set: { - "shipping.$.workflow.status": newStatus - }, - $push: { - "shipping.$.workflow.workflow": newStatus - } - }, - { bypassCollection2: true } - ); - - const updatedOrder = Orders.findOne({ _id: order._id }); - Promise.await(appEvents.emit("afterOrderUpdate", { - order: updatedOrder, - updatedBy: authUserId - })); - - return updateResult; -} From d12f1c3e5ad2c7413a6785f3d9e87f2c314a4e49 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Mon, 7 Oct 2019 13:44:44 -0700 Subject: [PATCH 2/9] refactor: remove unused server code for checkout package Signed-off-by: Erik Kieckhafer --- imports/plugins/core/checkout/server/index.js | 1 - .../core/checkout/server/methods/workflow.js | 154 ------------------ 2 files changed, 155 deletions(-) delete mode 100644 imports/plugins/core/checkout/server/index.js delete mode 100644 imports/plugins/core/checkout/server/methods/workflow.js diff --git a/imports/plugins/core/checkout/server/index.js b/imports/plugins/core/checkout/server/index.js deleted file mode 100644 index 09b8411d29a..00000000000 --- a/imports/plugins/core/checkout/server/index.js +++ /dev/null @@ -1 +0,0 @@ -import "./methods/workflow"; diff --git a/imports/plugins/core/checkout/server/methods/workflow.js b/imports/plugins/core/checkout/server/methods/workflow.js deleted file mode 100644 index 3edfd6a1dc7..00000000000 --- a/imports/plugins/core/checkout/server/methods/workflow.js +++ /dev/null @@ -1,154 +0,0 @@ -import _ from "lodash"; -import ReactionError from "@reactioncommerce/reaction-error"; -import { Meteor } from "meteor/meteor"; -import { check, Match } from "meteor/check"; -import { Orders } from "/lib/collections"; -import appEvents from "/imports/node-app/core/util/appEvents"; -import Reaction from "/imports/plugins/core/core/server/Reaction"; - -/* eslint no-shadow: 0 */ - -/** - * @file Methods for Workflow. Run these methods using `Meteor.call()`. - * @example Meteor.call("workflow/pushOrderWorkflow", "coreOrderWorkflow", "checkoutLogin"); - * - * @namespace Workflow/Methods - */ - -Meteor.methods({ - /** - * @name workflow/pushOrderWorkflow - * @summary Update the order workflow: Push the status as the current workflow step, - * move the current status to completed workflow steps - * - * @description Step 1 meteor call to push a new workflow - * Meteor.call("workflow/pushOrderWorkflow", "coreOrderWorkflow", "processing", this); - * NOTE: "coreOrderWorkflow", "processing" will be combined into "coreOrderWorkflow/processing" and set as the status - * Step 2 (this method) of the "workflow/pushOrderWorkflow" flow; Try to update the current status - * - * @method - * @memberof Workflow/Methods - * @param {String} workflow workflow to push to - * @param {String} status - Workflow status - * @param {Order} order - Schemas.Order, an order object - * @returns {Boolean} true if update was successful - */ - "workflow/pushOrderWorkflow"(workflow, status, order) { - check(workflow, String); - check(status, String); - check(order, Match.ObjectIncluding({ - _id: String, - shopId: String - })); - this.unblock(); - - if (!Reaction.hasPermission("orders", Reaction.getUserId(), order.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - - // Combine (workflow) "coreOrderWorkflow", (status) "processing" into "coreOrderWorkflow/processing". - // This combination will be used to call the method "workflow/coreOrderWorkflow/processing", if it exists. - const workflowStatus = `${workflow}/${status}`; - - const result = Orders.update({ - _id: order._id, - // Necessary to query on shop ID too, so they can't pass in a different ID for permission check - shopId: order.shopId - }, { - $set: { - "workflow.status": workflowStatus - }, - $addToSet: { - "workflow.workflow": workflowStatus - } - }); - if (result !== 1) { - throw new ReactionError("server-error", "Unable to update order"); - } - - const updatedOrder = Orders.findOne({ _id: order._id }); - Promise.await(appEvents.emit("afterOrderUpdate", { - order: updatedOrder, - updatedBy: Reaction.getUserId() - })); - - return result; - }, - - /** - * @name workflow/pushItemWorkflow - * @method - * @memberof Workflow/Methods - * @param {String} status Workflow status - * @param {Object} order Schemas.Order, an order object - * @param {String[]} itemIds Array of item IDs - * @returns {Boolean} true if update was successful - */ - "workflow/pushItemWorkflow"(status, order, itemIds) { - check(status, String); - check(order, Match.ObjectIncluding({ - _id: String, - shopId: String - })); - check(itemIds, Array); - - if (!Reaction.hasPermission("orders", Reaction.getUserId(), order.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - - // We can't trust the order from the client (for several reasons) - // Initially because in a multi-merchant scenario, the order from the client - // will contain only the items associated with their shop - // We'll get the order from the db that has all the items - - const dbOrder = Orders.findOne({ _id: order._id }); - - const shipping = dbOrder.shipping.map((group) => { - const items = group.items.map((item) => { - // Don't modify items unless they in our itemIds array - if (!itemIds.includes(item._id)) { - return item; - } - - // Add the current status to completed workflows - if (item.workflow.status !== "new") { - const workflows = item.workflow.workflow || []; - - workflows.push(status); - item.workflow.workflow = _.uniq(workflows); - } - - // Set the new item status - item.workflow.status = status; - return item; - }); - return { - ...group, - items - }; - }); - - const result = Orders.update({ - _id: dbOrder._id, - // Necessary to query on shop ID too, so they can't pass in a different ID for permission check - shopId: order.shopId - }, { - $set: { - shipping - } - }); - if (result !== 1) { - throw new ReactionError("server-error", "Unable to update order"); - } - - Promise.await(appEvents.emit("afterOrderUpdate", { - order: { - ...dbOrder, - shipping - }, - updatedBy: Reaction.getUserId() - })); - - return result; - } -}); From 72369f9f2593a15df6f6a0d535ea1e5f0502e191 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Mon, 7 Oct 2019 21:45:59 -0700 Subject: [PATCH 3/9] feat: add applyDiscountCodeToCart mutation Signed-off-by: Erik Kieckhafer --- .../node-app/plugins/discount-codes/index.js | 8 ++ .../mutations/applyDiscountCodeToCart.js | 134 ++++++++++++++++++ .../plugins/discount-codes/mutations/index.js | 7 + .../Mutation/applyDiscountCodeToCart.js | 39 +++++ .../resolvers/Mutation/index.js | 7 + .../plugins/discount-codes/resolvers/index.js | 5 + .../plugins/discount-codes/schemas/index.js | 3 + .../discount-codes/schemas/schema.graphql | 61 ++++++++ .../plugins/discount-codes/util/getCart.js | 53 +++++++ 9 files changed, 317 insertions(+) create mode 100644 imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js create mode 100644 imports/node-app/plugins/discount-codes/mutations/index.js create mode 100644 imports/node-app/plugins/discount-codes/resolvers/Mutation/applyDiscountCodeToCart.js create mode 100644 imports/node-app/plugins/discount-codes/resolvers/Mutation/index.js create mode 100644 imports/node-app/plugins/discount-codes/resolvers/index.js create mode 100644 imports/node-app/plugins/discount-codes/schemas/index.js create mode 100644 imports/node-app/plugins/discount-codes/schemas/schema.graphql create mode 100644 imports/node-app/plugins/discount-codes/util/getCart.js diff --git a/imports/node-app/plugins/discount-codes/index.js b/imports/node-app/plugins/discount-codes/index.js index 6d3797ffb1e..f9e3aad8567 100644 --- a/imports/node-app/plugins/discount-codes/index.js +++ b/imports/node-app/plugins/discount-codes/index.js @@ -3,6 +3,9 @@ import getCreditOffDiscount from "./util/getCreditOffDiscount.js"; import getItemPriceDiscount from "./util/getItemPriceDiscount.js"; import getPercentageOffDiscount from "./util/getPercentageOffDiscount.js"; import getShippingDiscount from "./util/getShippingDiscount.js"; +import mutations from "./mutations/index.js"; +import resolvers from "./resolvers/index.js"; +import schemas from "./schemas/index.js"; import startup from "./startup.js"; /** @@ -22,6 +25,11 @@ export default async function register(app) { "discounts/codes/shipping": [getShippingDiscount], "startup": [startup] }, + graphQL: { + resolvers, + schemas + }, + mutations, registry: [ { label: "Codes", diff --git a/imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js b/imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js new file mode 100644 index 00000000000..c99678d7509 --- /dev/null +++ b/imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js @@ -0,0 +1,134 @@ +import SimpleSchema from "simpl-schema"; +import Random from "@reactioncommerce/random"; +import ReactionError from "@reactioncommerce/reaction-error"; +import getCart from "../util/getCart.js"; + +const inputSchema = new SimpleSchema({ + cartId: String, + discountCode: String, + shopId: String, + token: { + type: String, + optional: true + } +}); + +/** + * @method applyDiscountCodeToCart + * @summary Applies a discount code to a cart + * @param {Object} context - an object containing the per-request state + * @param {Object} input - an object of all mutation arguments that were sent by the client + * @param {Object} input.cartId - Cart to add discount to + * @param {Object} input.discountCode - Discount code to add to cart + * @param {String} input.shopId - Shop cart belongs to + * @param {String} [input.token] - Cart token, if anonymous + * @returns {Promise} An object with the updated cart with the applied discount + */ +export default async function applyDiscountCodeToCart(context, input) { + inputSchema.validate(input); + + const { cartId, discountCode, shopId, token } = input; + const { collections, userHasPermission, userId } = context; + const { Cart, Discounts } = collections; + + // TODO: figure out the correct permission check here + // Should it be `discounts`, or `cart`? + // How do we determine this check if the user is the cart owner? + if (!userHasPermission(["admin", "owner", "discounts"], shopId)) { + throw new ReactionError("access-denied", "Access Denied"); + } + + let userCount = 0; + let orderCount = 0; + let cart = await getCart(context, shopId, cartId, { cartToken: token, throwIfNotFound: false }); + + // If we didn't find a cart, it means it belongs to another user, + // not the currently logged in user. + // Check to make sure current user has admin permission. + if (!cart) { + cart = await Cart.findOne({ _id: cartId }); + if (!cart) { + throw new ReactionError("not-found", "Cart not found"); + } + + // TODO: figure out the correct permission check here + // Should it be `discounts`, or `cart`? + if (!userHasPermission(["owner", "admin", "discounts"], shopId)) { + throw new ReactionError("access-denied", "Access Denied"); + } + } + + const objectToApplyDiscount = cart; + + // check to ensure discounts can only apply to single shop carts + // TODO: Remove this check after implementation of shop-by-shop discounts + // loop through all items and filter down to unique shops (in order to get participating shops in the order/cart) + const uniqueShopObj = objectToApplyDiscount.items.reduce((shopObj, item) => { + if (!shopObj[item.shopId]) { + shopObj[item.shopId] = true; + } + return shopObj; + }, {}); + const participatingShops = Object.keys(uniqueShopObj); + + if (participatingShops.length > 1) { + throw new ReactionError("not-implemented", "discounts.multiShopError", "Discounts cannot be applied to a multi-shop cart or order"); + } + + const discount = await Discounts.findOne({ code: discountCode }); + if (!discount) throw new ReactionError("not-found", `No discount found for code ${discountCode}`); + + const { conditions } = discount; + let accountLimitExceeded = false; + let discountLimitExceeded = false; + + // existing usage count + if (discount.transactions) { + const users = Array.from(discount.transactions, (trans) => trans.userId); + const transactionCount = new Map([...new Set(users)].map((userX) => [userX, users.filter((userY) => userY === userX).length])); + const orders = Array.from(discount.transactions, (trans) => trans.cartId); + userCount = transactionCount.get(userId); + orderCount = orders.length; + } + // check limits + if (conditions) { + if (conditions.accountLimit) accountLimitExceeded = conditions.accountLimit <= userCount; + if (conditions.redemptionLimit) discountLimitExceeded = conditions.redemptionLimit <= orderCount; + } + + // validate basic limit handling + if (accountLimitExceeded === true || discountLimitExceeded === true) { + return { i18nKeyLabel: "Code is expired", i18nKey: "discounts.codeIsExpired" }; + } + + if (!cart.billing) { + cart.billing = []; + } + + cart.billing.push({ + _id: Random.id(), + amount: discount.discount, + createdAt: new Date(), + currencyCode: objectToApplyDiscount.currencyCode, + data: { + discountId: discount._id, + code: discount.code + }, + displayName: `Discount Code: ${discount.code}`, + method: discount.calculation.method, + mode: "discount", + name: "discount_code", + paymentPluginName: "discount-codes", + processor: discount.discountMethod, + shopId: objectToApplyDiscount.shopId, + status: "created", + transactionId: Random.id() + }); + + // Instead of directly updating cart, we add the discount billing + // object from the existing cart, then pass to `saveCart` + // to re-run cart through all transforms and validations. + const savedCart = await context.mutations.saveCart(context, cart); + + return savedCart; +} diff --git a/imports/node-app/plugins/discount-codes/mutations/index.js b/imports/node-app/plugins/discount-codes/mutations/index.js new file mode 100644 index 00000000000..8256b3fb3eb --- /dev/null +++ b/imports/node-app/plugins/discount-codes/mutations/index.js @@ -0,0 +1,7 @@ +import applyDiscountCodeToCart from "./applyDiscountCodeToCart.js"; +import removeDiscountCodeFromCart from "./removeDiscountCodeFromCart.js"; + +export default { + applyDiscountCodeToCart, + removeDiscountCodeFromCart +}; diff --git a/imports/node-app/plugins/discount-codes/resolvers/Mutation/applyDiscountCodeToCart.js b/imports/node-app/plugins/discount-codes/resolvers/Mutation/applyDiscountCodeToCart.js new file mode 100644 index 00000000000..03591d2a3ff --- /dev/null +++ b/imports/node-app/plugins/discount-codes/resolvers/Mutation/applyDiscountCodeToCart.js @@ -0,0 +1,39 @@ +import { decodeCartOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/cart"; +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name Mutation/applyDiscountCodeToCart + * @method + * @memberof Fulfillment/GraphQL + * @summary resolver for the applyDiscountCodeToCart GraphQL mutation + * @param {Object} parentResult - unused + * @param {Object} args.input - an object of all mutation arguments that were sent by the client + * @param {Object} args.input.cartId - Cart to add discount to + * @param {Object} args.input.discountCode - Discount code to add to cart + * @param {String} args.input.shopId - Shop cart belongs to + * @param {String} [args.input.token] - Cart token, if anonymous + * @param {String} [args.input.clientMutationId] - An optional string identifying the mutation call + * @param {Object} context - an object containing the per-request state + * @returns {Promise} applyDiscountCodeToCartPayload + */ +export default async function applyDiscountCodeToCart(parentResult, { input }, context) { + const { + clientMutationId = null, + cartId, + discountCode, + shopId, + token + } = input; + + const updatedCartWithAppliedDiscountCode = await context.mutations.applyDiscountCodeToCart(context, { + cartId: decodeCartOpaqueId(cartId), + discountCode, + shopId: decodeShopOpaqueId(shopId), + token + }); + + return { + clientMutationId, + cart: updatedCartWithAppliedDiscountCode + }; +} diff --git a/imports/node-app/plugins/discount-codes/resolvers/Mutation/index.js b/imports/node-app/plugins/discount-codes/resolvers/Mutation/index.js new file mode 100644 index 00000000000..8256b3fb3eb --- /dev/null +++ b/imports/node-app/plugins/discount-codes/resolvers/Mutation/index.js @@ -0,0 +1,7 @@ +import applyDiscountCodeToCart from "./applyDiscountCodeToCart.js"; +import removeDiscountCodeFromCart from "./removeDiscountCodeFromCart.js"; + +export default { + applyDiscountCodeToCart, + removeDiscountCodeFromCart +}; diff --git a/imports/node-app/plugins/discount-codes/resolvers/index.js b/imports/node-app/plugins/discount-codes/resolvers/index.js new file mode 100644 index 00000000000..6b9c90688a3 --- /dev/null +++ b/imports/node-app/plugins/discount-codes/resolvers/index.js @@ -0,0 +1,5 @@ +import Mutation from "./Mutation/index.js"; + +export default { + Mutation +}; diff --git a/imports/node-app/plugins/discount-codes/schemas/index.js b/imports/node-app/plugins/discount-codes/schemas/index.js new file mode 100644 index 00000000000..cc293a21b1e --- /dev/null +++ b/imports/node-app/plugins/discount-codes/schemas/index.js @@ -0,0 +1,3 @@ +import schema from "./schema.graphql"; + +export default [schema]; diff --git a/imports/node-app/plugins/discount-codes/schemas/schema.graphql b/imports/node-app/plugins/discount-codes/schemas/schema.graphql new file mode 100644 index 00000000000..6cef099e978 --- /dev/null +++ b/imports/node-app/plugins/discount-codes/schemas/schema.graphql @@ -0,0 +1,61 @@ +extend type Mutation { + "Apply a discount code to a cart" + applyDiscountCodeToCart( + "Mutation input" + input: ApplyDiscountCodeToCartInput! + ): ApplyDiscountCodeToCartPayload! + + "Remove a discount code from a cart" + removeDiscountCodeFromCart( + "Mutation input" + input: RemoveDiscountCodeFromCartInput! + ): RemoveDiscountCodeFromCartPayload! +} + +"Input for an `ApplyDiscountCodeToCartInput`" +input ApplyDiscountCodeToCartInput { + "Cart to add discount to" + cartId: ID! + + "Discount code to add to cart" + discountCode: String! + + "Shop cart belongs to" + shopId: ID! + + "Cart token, if anonymous" + token: String +} + +"Input for an `RemoveDiscountCodeFromCartInput`" +input RemoveDiscountCodeFromCartInput { + "Cart to add discount to" + cartId: ID! + + "Discount code to add to cart" + discountCodeId: ID! + + "Shop cart belongs to" + shopId: ID! + + "Cart token, if anonymous" + token: String +} + +"Response from the `applyDiscountCodeToCart` mutation" +type ApplyDiscountCodeToCartPayload { + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String + + "The updated cart with discount code applied" + cart: Cart! +} + +"Response from the `removeDiscountCodeFromCart` mutation" +type RemoveDiscountCodeFromCartPayload { + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String + + "The updated cart with discount code removed" + cart: Cart! +} diff --git a/imports/node-app/plugins/discount-codes/util/getCart.js b/imports/node-app/plugins/discount-codes/util/getCart.js new file mode 100644 index 00000000000..ead7cae96e2 --- /dev/null +++ b/imports/node-app/plugins/discount-codes/util/getCart.js @@ -0,0 +1,53 @@ +import hashToken from "@reactioncommerce/api-utils/hashToken.js"; +import Logger from "@reactioncommerce/logger"; +import ReactionError from "@reactioncommerce/reaction-error"; + +/** + * @summary Gets the current cart. + * @param {Object} context - an object containing the per-request state + * @param {String} shopId shopId cart belongs to + * @param {String} [cartId] Limit the search by this cart ID if provided. + * @param {Object} [options] Options + * @param {String} [options.cartToken] Cart token, required if it's an anonymous cart + * @param {Boolean} [options.throwIfNotFound] Default false. Throw a not-found error rather than return null `cart` + * @returns {Object} A cart object + */ +export default async function getCart(context, shopId, cartId, { cartToken, throwIfNotFound = false } = {}) { + const { collections, userId } = context; + const { Accounts, Cart } = collections; + + // set shopId to selector + const selector = { shopId }; + + // if we have a cartId, add it to selector + if (cartId) { + selector._id = cartId; + } + + // if there is a cartToken, the cart is anonymous + if (cartToken) { + selector.anonymousAccessToken = hashToken(cartToken); + } else { + const account = (userId && await Accounts.findOne({ userId })) || null; + + if (!account) { + if (throwIfNotFound) { + Logger.error(`Cart not found for user with ID ${userId}`); + throw new ReactionError("not-found", "Cart not found"); + } + + return null; + } + + selector.accountId = account._id; + } + + const cart = await Cart.findOne(selector) || null; + + if (!cart && throwIfNotFound) { + Logger.error(`Cart not found for user with ID ${userId}`); + throw new ReactionError("not-found", "Cart not found"); + } + + return cart; +} From 10d012db000f5dbe4e2b6fbe35bde3e51a166630 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Mon, 7 Oct 2019 21:57:04 -0700 Subject: [PATCH 4/9] feat: add removeDiscountCodeFromCart mutation Signed-off-by: Erik Kieckhafer --- .../mutations/removeDiscountCodeFromCart.js | 67 +++++++++++++++++++ .../Mutation/removeDiscountCodeFromCart.js | 40 +++++++++++ 2 files changed, 107 insertions(+) create mode 100644 imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js create mode 100644 imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js diff --git a/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js b/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js new file mode 100644 index 00000000000..698e78ff338 --- /dev/null +++ b/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js @@ -0,0 +1,67 @@ +import SimpleSchema from "simpl-schema"; +import ReactionError from "@reactioncommerce/reaction-error"; +import getCart from "../util/getCart.js"; + +const inputSchema = new SimpleSchema({ + cartId: String, + discountCodeId: String, + shopId: String, + token: { + type: String, + optional: true + } +}); + +/** + * @method removeDiscountCodeFromCart + * @summary Applies a discount code to a cart + * @param {Object} context - an object containing the per-request state + * @param {Object} input - an object of all mutation arguments that were sent by the client + * @param {Object} input.cartId - Cart to remove discount from + * @param {Object} input.discountCodeId - Discount code to remove from cart + * @param {String} input.shopId - Shop cart belongs to + * @param {String} [input.token] - Cart token, if anonymous + * @returns {Promise} An object with the updated cart with the removed discount + */ +export default async function removeDiscountCodeFromCart(context, input) { + inputSchema.validate(input); + + const { cartId, discountCodeId, shopId, token } = input; + const { collections, userHasPermission } = context; + const { Cart } = collections; + + // TODO: figure out the correct permission check here + // Should it be `discounts`, or `cart`? + // How do we determine this check if the user is the cart owner? + if (!userHasPermission(["admin", "owner", "discounts"], shopId)) { + throw new ReactionError("access-denied", "Access Denied"); + } + + let cart = await getCart(context, shopId, cartId, { cartToken: token, throwIfNotFound: false }); + + // If we didn't find a cart, it means it belongs to another user, + // not the currently logged in user. + // Check to make sure current user has admin permission. + if (!cart) { + cart = await Cart.findOne({ _id: cartId }); + if (!cart) { + throw new ReactionError("not-found", "Cart not found"); + } + + // TODO: figure out the correct permission check here + // Should it be `discounts`, or `cart`? + if (!userHasPermission(["owner", "admin", "discounts"], shopId)) { + throw new ReactionError("access-denied", "Access Denied"); + } + } + + // Instead of directly updating cart, we remove the discount billing + // object from the existing cart, then pass to `saveCart` + // to re-run cart through all transforms and validations. + const updatedCartBilling = cart.billing.filter((doc) => doc._id !== discountCodeId); + cart.billing = updatedCartBilling; + + const savedCart = await context.mutations.saveCart(context, cart); + + return savedCart; +} diff --git a/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js b/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js new file mode 100644 index 00000000000..9184282d6e7 --- /dev/null +++ b/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js @@ -0,0 +1,40 @@ +import { decodeCartOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/cart"; +import { decodePaymentOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/payment"; +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name Mutation/removeDiscountCodeFromCart + * @method + * @memberof Fulfillment/GraphQL + * @summary resolver for the removeDiscountCodeFromCart GraphQL mutation + * @param {Object} parentResult - unused + * @param {Object} args.input - an object of all mutation arguments that were sent by the client + * @param {Object} args.input.cartId - Cart to remove discount from + * @param {Object} args.input.discountCodeId - Discount code Id to remove from cart + * @param {String} args.input.shopId - Shop cart belongs to + * @param {String} [args.input.token] - Cart token, if anonymous + * @param {String} [args.input.clientMutationId] - An optional string identifying the mutation call + * @param {Object} context - an object containing the per-request state + * @returns {Promise} removeDiscountCodeFromCartPayload + */ +export default async function removeDiscountCodeFromCart(parentResult, { input }, context) { + const { + clientMutationId = null, + cartId, + discountCodeId, + shopId, + token + } = input; + + const updatedCartWithRemovedDiscountCode = await context.mutations.removeDiscountCodeFromCart(context, { + cartId: decodeCartOpaqueId(cartId), + discountCodeId: decodePaymentOpaqueId(discountCodeId), + shopId: decodeShopOpaqueId(shopId), + token + }); + + return { + clientMutationId, + cart: updatedCartWithRemovedDiscountCode + }; +} From 32b96fd92ce93c0e750391e301ef33b6a9421ee9 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Mon, 7 Oct 2019 22:00:22 -0700 Subject: [PATCH 5/9] refactor: remove discounts/codes/apply and discounts/codes/remove meteor methods Signed-off-by: Erik Kieckhafer --- .../discount-codes/server/methods/methods.js | 195 ------------------ 1 file changed, 195 deletions(-) diff --git a/imports/plugins/included/discount-codes/server/methods/methods.js b/imports/plugins/included/discount-codes/server/methods/methods.js index 97b2360cbe7..e5e947fd524 100644 --- a/imports/plugins/included/discount-codes/server/methods/methods.js +++ b/imports/plugins/included/discount-codes/server/methods/methods.js @@ -79,201 +79,6 @@ export const methods = { _id: discountId, shopId }); - }, - - /** - * @name discounts/codes/remove - * @method - * @memberof Discounts/Codes/Methods - * @summary removes discounts that have been previously applied to a cart. - * @param {String} id cart id of which to remove a code - * @param {String} codeId discount Id from cart.billing - * @param {String} collection collection (either Orders or Cart) - * @param {String} [token] Cart or order token if anonymous - * @returns {String} returns update/insert result - */ - "discounts/codes/remove"(id, codeId, collection = "Cart", token) { - check(id, String); - check(codeId, String); - check(collection, String); - check(token, Match.Maybe(String)); - - const Collection = Reaction.Collections[collection]; - - if (collection === "Cart") { - let { cart } = getCart(id, { cartToken: token, throwIfNotFound: true }); - - // If we found a cart, then the current account owns it - if (!cart) { - cart = Collection.findOne({ _id: id }); - if (!cart) { - throw new ReactionError("not-found", "Cart not found"); - } - - if (!Reaction.hasPermission("discounts", Reaction.getUserId(), cart.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - } - } else { - const order = Collection.findOne({ _id: id }); - if (!order) { - throw new ReactionError("not-found", "Order not found"); - } - - if (!Reaction.hasPermission("discounts", Reaction.getUserId(), order.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - } - - // TODO: update a history record of transaction - // The Payment schema currency defaultValue is adding {} to the $pull condition. - // If this issue is eventually fixed, autoValues can be re-enabled here - // See https://github.com/aldeed/simple-schema-js/issues/272 - const result = Collection.update( - { _id: id }, - { $pull: { billing: { _id: codeId } } }, - { getAutoValues: false } - ); - - if (collection === "Cart") { - const updatedCart = Collection.findOne({ _id: id }); - Promise.await(appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: Reaction.getUserId() - })); - } - - return result; - }, - - /** - * @name discounts/codes/apply - * @method - * @memberof Discounts/Codes/Methods - * @summary checks validity of code conditions and then applies a discount as a paymentMethod to cart - * @param {String} id cart/order id of which to remove a code - * @param {String} code valid discount code - * @param {String} collection collection (either Orders or Cart) - * @param {String} [token] Cart or order token if anonymous - * @returns {Boolean} returns true if successfully applied - */ - "discounts/codes/apply"(id, code, collection = "Cart", token) { - check(id, String); - check(code, String); - check(collection, String); - check(token, Match.Maybe(String)); - let userCount = 0; - let orderCount = 0; - - const Collection = Reaction.Collections[collection]; - let objectToApplyDiscount; - - if (collection === "Cart") { - let { cart } = getCart(id, { cartToken: token, throwIfNotFound: true }); - - // If we found a cart, then the current account owns it - if (!cart) { - cart = Collection.findOne({ _id: id }); - if (!cart) { - throw new ReactionError("not-found", "Cart not found"); - } - - if (!Reaction.hasPermission("discounts", Reaction.getUserId(), cart.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - } - - objectToApplyDiscount = cart; - } else { - const order = Collection.findOne({ _id: id }); - if (!order) { - throw new ReactionError("not-found", "Order not found"); - } - - if (!Reaction.hasPermission("discounts", Reaction.getUserId(), order.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - - objectToApplyDiscount = order; - } - - // check to ensure discounts can only apply to single shop carts - // TODO: Remove this check after implementation of shop-by-shop discounts - // loop through all items and filter down to unique shops (in order to get participating shops in the order/cart) - const uniqueShopObj = objectToApplyDiscount.items.reduce((shopObj, item) => { - if (!shopObj[item.shopId]) { - shopObj[item.shopId] = true; - } - return shopObj; - }, {}); - const participatingShops = Object.keys(uniqueShopObj); - - if (participatingShops.length > 1) { - throw new ReactionError("not-implemented", "discounts.multiShopError", "Discounts cannot be applied to a multi-shop cart or order"); - } - - const discount = Discounts.findOne({ code }); - if (!discount) throw new ReactionError("not-found", `No discount found for code ${code}`); - - const { conditions } = discount; - let accountLimitExceeded = false; - let discountLimitExceeded = false; - - // existing usage count - if (discount.transactions) { - const users = Array.from(discount.transactions, (trans) => trans.userId); - const transactionCount = new Map([...new Set(users)].map((userX) => [userX, users.filter((userY) => userY === userX).length])); - const orders = Array.from(discount.transactions, (trans) => trans.cartId); - userCount = transactionCount.get(Reaction.getUserId()); - orderCount = orders.length; - } - // check limits - if (conditions) { - if (conditions.accountLimit) accountLimitExceeded = conditions.accountLimit <= userCount; - if (conditions.redemptionLimit) discountLimitExceeded = conditions.redemptionLimit <= orderCount; - } - - // validate basic limit handling - if (accountLimitExceeded === true || discountLimitExceeded === true) { - return { i18nKeyLabel: "Code is expired", i18nKey: "discounts.codeIsExpired" }; - } - - const now = new Date(); - const result = Collection.update({ - _id: id - }, { - $addToSet: { - billing: { - _id: Random.id(), - amount: discount.discount, - createdAt: now, - currencyCode: objectToApplyDiscount.currencyCode, - data: { - discountId: discount._id, - code: discount.code - }, - displayName: `Discount Code: ${discount.code}`, - method: discount.calculation.method, - mode: "discount", - name: "discount_code", - paymentPluginName: "discount-codes", - processor: discount.discountMethod, - shopId: objectToApplyDiscount.shopId, - status: "created", - transactionId: Random.id() - } - } - }); - - if (collection === "Cart") { - const updatedCart = Collection.findOne({ _id: id }); - Promise.await(appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: Reaction.getUserId() - })); - } - - return result; } }; From 6cb2014487d2b855303c5e01f46fe7560c961b62 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Mon, 7 Oct 2019 22:03:45 -0700 Subject: [PATCH 6/9] refactor: remove unused client code that was calling unused meteor methods Signed-off-by: Erik Kieckhafer --- .../core/discounts/client/components/form.js | 166 ------------------ .../core/discounts/client/components/list.js | 107 ----------- 2 files changed, 273 deletions(-) delete mode 100644 imports/plugins/core/discounts/client/components/form.js delete mode 100644 imports/plugins/core/discounts/client/components/list.js diff --git a/imports/plugins/core/discounts/client/components/form.js b/imports/plugins/core/discounts/client/components/form.js deleted file mode 100644 index f84b42e8c2a..00000000000 --- a/imports/plugins/core/discounts/client/components/form.js +++ /dev/null @@ -1,166 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import debounce from "lodash/debounce"; -import { Meteor } from "meteor/meteor"; -import { i18next } from "/client/api"; -import { Translation } from "/imports/plugins/core/ui/client/components"; -import { Components } from "@reactioncommerce/reaction-components"; - -export default class DiscountForm extends Component { - constructor(props) { - super(props); - this.state = { - discount: this.props.discount, - validationMessage: null, - validatedInput: this.props.validatedInput || false, - attempts: 0, - discountApplied: false - }; - // debounce helper so to wait on user input - this.debounceDiscounts = debounce(() => { - this.setState({ validationMessage: "" }); - const { collection, id, token } = this.props; - const { discount } = this.state; - // handle discount code validation messages after attempt to apply - Meteor.call("discounts/codes/apply", id, discount, collection, token, (error, result) => { - if (error) { - Alerts.toast(i18next.t(error.reason), "error"); - } - - if (typeof result === "object") { - this.setState({ validationMessage: result }); - } else if (result !== 1) { - // if validationMessage isn't an object with i18n - // we will display an elliptical that's not - // actually done here though, just bit of foolery - this.timerId = Meteor.setTimeout(() => { - this.setState({ validationMessage: "..." }); - }, 2000); - } - }); - }, 800); - } - - componentWillUnmount() { - if (this.timerId) Meteor.clearInterval(this.timerId); - } - - // handle apply - renderApplied() { - return ( - - ); - } - - // handle keydown and change events - handleChange = (event) => { - const { attempts } = this.state; - // ensure we don't submit on enter - if (event.keyCode === 13) { - event.preventDefault(); - event.stopPropagation(); - } - // clear input if user hits escape key - if (event.keyCode === 27) { - return this.setState({ discount: "", validatedInput: false, attempts: 0, discountApplied: false, validationMessage: null }); - } - this.setState({ - discount: event.target.value, - attempts: attempts + 1 - }); - // TODO: this.debounce doesn't always need to exec we should add some logic to determine based on attempts or some other - // cleverness if now is a good time to apply the code. - return this.debounceDiscounts(); - } - - // handle display or not - handleClick = (event) => { - event.preventDefault(); - this.setState({ validatedInput: true }); - } - - // loader button - loader() { - const { attempts, discount, validationMessage } = this.state; - let loader; - if (validationMessage && validationMessage.i18nKeyLabel && attempts > 3) { - loader = ; - } else if (validationMessage) { - loader = ; - } else if (discount && discount.length >= 10 && attempts >= 12) { - loader = ; - } else if (discount && discount.length >= 2 && attempts >= 2) { - loader = ; - } else { - loader = ; - } - - return loader; - } - - // render discount form - renderDiscountForm() { - return ( -
- -
- - - {this.loader()} - -
-
- ); - } - // have a code link - renderDiscountLink() { - return ( - - ); - } - - // render discount code input form - render() { - const { discountApplied, validatedInput } = this.state; - if (discountApplied === true && validatedInput === true) { - return this.renderApplied(); - } else if (validatedInput === true) { - return this.renderDiscountForm(); - } - return this.renderDiscountLink(); - } -} - -DiscountForm.propTypes = { - collection: PropTypes.string, - discount: PropTypes.string, - id: PropTypes.string, - token: PropTypes.string, - validatedInput: PropTypes.bool // eslint-disable-line react/boolean-prop-naming -}; diff --git a/imports/plugins/core/discounts/client/components/list.js b/imports/plugins/core/discounts/client/components/list.js deleted file mode 100644 index 15c02e3f779..00000000000 --- a/imports/plugins/core/discounts/client/components/list.js +++ /dev/null @@ -1,107 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { Meteor } from "meteor/meteor"; -import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; -import { IconButton } from "/imports/plugins/core/ui/client/components"; -import { Reaction } from "/client/api"; -import DiscountForm from "./form"; - -class DiscountList extends Component { - constructor(props) { - super(props); - this.handleClick = this.handleClick.bind(this); - } - - // handle remove click - handleClick(event, codeId) { - const { collection, id, token } = this.props; - return Meteor.call("discounts/codes/remove", id, codeId, collection, token); - } - - // list items - renderList() { - const listItems = this.props.listItems.map((listItem) => this.renderItem(listItem)); - - return ( -
{listItems}
- ); - } - - // render item - renderItem(listItem) { - let TrashCan; - - if (this.props.collection !== "Orders") { - TrashCan = ( -
- this.handleClick(event, listItem._id)}/> -
- ); - } - return ( -
- - {listItem.displayName} - - {TrashCan} -
- ); - } - - // load form input view - renderNoneFound() { - const { collection, id, token, validatedInput } = this.props; - return ( - - ); - } - - // render list view - render() { - const { listItems } = this.props; - return (listItems.length >= 1) ? this.renderList() : this.renderNoneFound(); - } -} - -DiscountList.propTypes = { - collection: PropTypes.string, - id: PropTypes.string, - listItems: PropTypes.array, - token: PropTypes.string, - validatedInput: PropTypes.bool // eslint-disable-line react/boolean-prop-naming -}; - -/** - * @summary Tracker reactive props - * @param {Object} props Incoming props - * @param {Function} onData Callback for more props - * @returns {undefined} - */ -function composer(props, onData) { - const currentCart = Reaction.Collections[props.collection].findOne({ - _id: props.id - }); - - const listItems = (currentCart.billing || []).reduce((list, item) => { - if (item.mode === "discount") { - list.push({ - _id: item._id, - displayName: item.displayName - }); - } - return list; - }, []); - - onData(null, { - listItems - }); -} - -const options = { - propsToWatch: ["billing"] -}; - -const discountListComponent = composeWithTracker(composer, options)(DiscountList); -registerComponent("DiscountList", discountListComponent); - -export default discountListComponent; From fd24bcb8e4c756bddd72fc47ef876e2e7beaaf66 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Mon, 7 Oct 2019 22:15:59 -0700 Subject: [PATCH 7/9] style: lint fixes Signed-off-by: Erik Kieckhafer --- .../plugins/discount-codes/schemas/schema.graphql | 12 ++++++------ .../discount-codes/server/methods/methods.js | 5 +---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/imports/node-app/plugins/discount-codes/schemas/schema.graphql b/imports/node-app/plugins/discount-codes/schemas/schema.graphql index 6cef099e978..17a564bbb4f 100644 --- a/imports/node-app/plugins/discount-codes/schemas/schema.graphql +++ b/imports/node-app/plugins/discount-codes/schemas/schema.graphql @@ -44,18 +44,18 @@ input RemoveDiscountCodeFromCartInput { "Response from the `applyDiscountCodeToCart` mutation" type ApplyDiscountCodeToCartPayload { - "The same string you sent with the mutation params, for matching mutation calls with their responses" - clientMutationId: String - "The updated cart with discount code applied" cart: Cart! -} -"Response from the `removeDiscountCodeFromCart` mutation" -type RemoveDiscountCodeFromCartPayload { "The same string you sent with the mutation params, for matching mutation calls with their responses" clientMutationId: String +} +"Response from the `removeDiscountCodeFromCart` mutation" +type RemoveDiscountCodeFromCartPayload { "The updated cart with discount code removed" cart: Cart! + + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String } diff --git a/imports/plugins/included/discount-codes/server/methods/methods.js b/imports/plugins/included/discount-codes/server/methods/methods.js index e5e947fd524..d1eab9ae536 100644 --- a/imports/plugins/included/discount-codes/server/methods/methods.js +++ b/imports/plugins/included/discount-codes/server/methods/methods.js @@ -1,11 +1,8 @@ -import Random from "@reactioncommerce/random"; import { Meteor } from "meteor/meteor"; -import { check, Match } from "meteor/check"; +import { check } from "meteor/check"; import Reaction from "/imports/plugins/core/core/server/Reaction"; import ReactionError from "@reactioncommerce/reaction-error"; -import appEvents from "/imports/node-app/core/util/appEvents"; import { Discounts } from "/imports/plugins/core/discounts/lib/collections"; -import getCart from "../util/getCart"; import { DiscountCodes as DiscountSchema } from "../../lib/collections/schemas"; /** From f6b1d0a161dc3e20044ff34b36cc7ccbf1d0508a Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Tue, 8 Oct 2019 10:21:30 -0700 Subject: [PATCH 8/9] refactor: permission fixes and namespace Signed-off-by: Erik Kieckhafer --- .../node-app/plugins/discount-codes/index.js | 5 ++++ .../mutations/applyDiscountCodeToCart.js | 28 ++----------------- .../mutations/removeDiscountCodeFromCart.js | 22 ++++++--------- .../Mutation/removeDiscountCodeFromCart.js | 4 +-- .../server/no-meteor/util/namespaces.js | 1 + .../server/no-meteor/xforms/discount.js | 7 +++++ imports/utils/graphql/namespaces.js | 1 + 7 files changed, 26 insertions(+), 42 deletions(-) create mode 100644 imports/plugins/core/graphql/server/no-meteor/xforms/discount.js diff --git a/imports/node-app/plugins/discount-codes/index.js b/imports/node-app/plugins/discount-codes/index.js index f9e3aad8567..090b03771c2 100644 --- a/imports/node-app/plugins/discount-codes/index.js +++ b/imports/node-app/plugins/discount-codes/index.js @@ -38,6 +38,11 @@ export default async function register(app) { }, { provides: ["paymentMethod"], template: "discountCodesCheckout" + }, { + route: "discounts/apply", + label: "Apply Discounts", + permission: "applyDiscounts", + name: "discounts/apply" } ] }); diff --git a/imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js b/imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js index c99678d7509..4b3d33ab7da 100644 --- a/imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js +++ b/imports/node-app/plugins/discount-codes/mutations/applyDiscountCodeToCart.js @@ -31,13 +31,6 @@ export default async function applyDiscountCodeToCart(context, input) { const { collections, userHasPermission, userId } = context; const { Cart, Discounts } = collections; - // TODO: figure out the correct permission check here - // Should it be `discounts`, or `cart`? - // How do we determine this check if the user is the cart owner? - if (!userHasPermission(["admin", "owner", "discounts"], shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - let userCount = 0; let orderCount = 0; let cart = await getCart(context, shopId, cartId, { cartToken: token, throwIfNotFound: false }); @@ -51,30 +44,13 @@ export default async function applyDiscountCodeToCart(context, input) { throw new ReactionError("not-found", "Cart not found"); } - // TODO: figure out the correct permission check here - // Should it be `discounts`, or `cart`? - if (!userHasPermission(["owner", "admin", "discounts"], shopId)) { + if (!userHasPermission(["owner", "admin", "discounts/apply"], shopId)) { throw new ReactionError("access-denied", "Access Denied"); } } const objectToApplyDiscount = cart; - // check to ensure discounts can only apply to single shop carts - // TODO: Remove this check after implementation of shop-by-shop discounts - // loop through all items and filter down to unique shops (in order to get participating shops in the order/cart) - const uniqueShopObj = objectToApplyDiscount.items.reduce((shopObj, item) => { - if (!shopObj[item.shopId]) { - shopObj[item.shopId] = true; - } - return shopObj; - }, {}); - const participatingShops = Object.keys(uniqueShopObj); - - if (participatingShops.length > 1) { - throw new ReactionError("not-implemented", "discounts.multiShopError", "Discounts cannot be applied to a multi-shop cart or order"); - } - const discount = await Discounts.findOne({ code: discountCode }); if (!discount) throw new ReactionError("not-found", `No discount found for code ${discountCode}`); @@ -98,7 +74,7 @@ export default async function applyDiscountCodeToCart(context, input) { // validate basic limit handling if (accountLimitExceeded === true || discountLimitExceeded === true) { - return { i18nKeyLabel: "Code is expired", i18nKey: "discounts.codeIsExpired" }; + throw new ReactionError("error-occurred", "Code is expired"); } if (!cart.billing) { diff --git a/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js b/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js index 698e78ff338..d5a30fcae76 100644 --- a/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js +++ b/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js @@ -30,28 +30,19 @@ export default async function removeDiscountCodeFromCart(context, input) { const { collections, userHasPermission } = context; const { Cart } = collections; - // TODO: figure out the correct permission check here - // Should it be `discounts`, or `cart`? - // How do we determine this check if the user is the cart owner? - if (!userHasPermission(["admin", "owner", "discounts"], shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - let cart = await getCart(context, shopId, cartId, { cartToken: token, throwIfNotFound: false }); // If we didn't find a cart, it means it belongs to another user, // not the currently logged in user. // Check to make sure current user has admin permission. if (!cart) { - cart = await Cart.findOne({ _id: cartId }); - if (!cart) { - throw new ReactionError("not-found", "Cart not found"); + if (!userHasPermission(["owner", "admin", "discounts/apply"], shopId)) { + throw new ReactionError("access-denied", "Access Denied"); } - // TODO: figure out the correct permission check here - // Should it be `discounts`, or `cart`? - if (!userHasPermission(["owner", "admin", "discounts"], shopId)) { - throw new ReactionError("access-denied", "Access Denied"); + cart = await Cart.findOne({ _id: cartId, shopId }); + if (!cart) { + throw new ReactionError("not-found", "Cart not found"); } } @@ -59,6 +50,9 @@ export default async function removeDiscountCodeFromCart(context, input) { // object from the existing cart, then pass to `saveCart` // to re-run cart through all transforms and validations. const updatedCartBilling = cart.billing.filter((doc) => doc._id !== discountCodeId); + if (cart.billing.length !== updatedCartBilling.length) { + throw new ReactionError("not-found", "Discount Code not found"); + } cart.billing = updatedCartBilling; const savedCart = await context.mutations.saveCart(context, cart); diff --git a/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js b/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js index 9184282d6e7..3c8f502d7f1 100644 --- a/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js +++ b/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js @@ -1,5 +1,5 @@ import { decodeCartOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/cart"; -import { decodePaymentOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/payment"; +import { decodeDiscountOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/discount"; import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; /** @@ -28,7 +28,7 @@ export default async function removeDiscountCodeFromCart(parentResult, { input } const updatedCartWithRemovedDiscountCode = await context.mutations.removeDiscountCodeFromCart(context, { cartId: decodeCartOpaqueId(cartId), - discountCodeId: decodePaymentOpaqueId(discountCodeId), + discountCodeId: decodeDiscountOpaqueId(discountCodeId), shopId: decodeShopOpaqueId(shopId), token }); diff --git a/imports/plugins/core/graphql/server/no-meteor/util/namespaces.js b/imports/plugins/core/graphql/server/no-meteor/util/namespaces.js index 5872b1232d4..4a0e3ee6d12 100644 --- a/imports/plugins/core/graphql/server/no-meteor/util/namespaces.js +++ b/imports/plugins/core/graphql/server/no-meteor/util/namespaces.js @@ -7,6 +7,7 @@ export default { Cart: "reaction/cart", CartItem: "reaction/cartItem", Currency: "reaction/currency", + Discount: "reaction/discount", FulfillmentGroup: "reaction/fulfillmentGroup", FulfillmentMethod: "reaction/fulfillmentMethod", Group: "reaction/group", diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/discount.js b/imports/plugins/core/graphql/server/no-meteor/xforms/discount.js new file mode 100644 index 00000000000..26c3b4b1573 --- /dev/null +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/discount.js @@ -0,0 +1,7 @@ +import namespaces from "@reactioncommerce/api-utils/graphql/namespaces.js"; +import { assocInternalId, assocOpaqueId, decodeOpaqueIdForNamespace, encodeOpaqueId } from "./id"; + +export const assocDiscountInternalId = assocInternalId(namespaces.Discount); +export const assocDiscountOpaqueId = assocOpaqueId(namespaces.Discount); +export const decodeDiscountOpaqueId = decodeOpaqueIdForNamespace(namespaces.Discount); +export const encodeDiscountOpaqueId = encodeOpaqueId(namespaces.Discount); diff --git a/imports/utils/graphql/namespaces.js b/imports/utils/graphql/namespaces.js index 5872b1232d4..4a0e3ee6d12 100644 --- a/imports/utils/graphql/namespaces.js +++ b/imports/utils/graphql/namespaces.js @@ -7,6 +7,7 @@ export default { Cart: "reaction/cart", CartItem: "reaction/cartItem", Currency: "reaction/currency", + Discount: "reaction/discount", FulfillmentGroup: "reaction/fulfillmentGroup", FulfillmentMethod: "reaction/fulfillmentMethod", Group: "reaction/group", From 809b700b77e05744f5271dc5f55d3a1cc4ca297d Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 8 Oct 2019 13:40:06 -0500 Subject: [PATCH 9/9] fix: use correct ID logic and rename discountCodeId to discountId Signed-off-by: Eric Dobbertin --- .../mutations/removeDiscountCodeFromCart.js | 12 ++++++------ .../resolvers/Mutation/removeDiscountCodeFromCart.js | 6 +++--- .../plugins/discount-codes/schemas/schema.graphql | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js b/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js index d5a30fcae76..bd480dfae3d 100644 --- a/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js +++ b/imports/node-app/plugins/discount-codes/mutations/removeDiscountCodeFromCart.js @@ -4,7 +4,7 @@ import getCart from "../util/getCart.js"; const inputSchema = new SimpleSchema({ cartId: String, - discountCodeId: String, + discountId: String, shopId: String, token: { type: String, @@ -18,7 +18,7 @@ const inputSchema = new SimpleSchema({ * @param {Object} context - an object containing the per-request state * @param {Object} input - an object of all mutation arguments that were sent by the client * @param {Object} input.cartId - Cart to remove discount from - * @param {Object} input.discountCodeId - Discount code to remove from cart + * @param {Object} input.discountId - Discount code to remove from cart * @param {String} input.shopId - Shop cart belongs to * @param {String} [input.token] - Cart token, if anonymous * @returns {Promise} An object with the updated cart with the removed discount @@ -26,7 +26,7 @@ const inputSchema = new SimpleSchema({ export default async function removeDiscountCodeFromCart(context, input) { inputSchema.validate(input); - const { cartId, discountCodeId, shopId, token } = input; + const { cartId, discountId, shopId, token } = input; const { collections, userHasPermission } = context; const { Cart } = collections; @@ -49,9 +49,9 @@ export default async function removeDiscountCodeFromCart(context, input) { // Instead of directly updating cart, we remove the discount billing // object from the existing cart, then pass to `saveCart` // to re-run cart through all transforms and validations. - const updatedCartBilling = cart.billing.filter((doc) => doc._id !== discountCodeId); - if (cart.billing.length !== updatedCartBilling.length) { - throw new ReactionError("not-found", "Discount Code not found"); + const updatedCartBilling = cart.billing.filter((doc) => doc._id !== discountId); + if (cart.billing.length === updatedCartBilling.length) { + throw new ReactionError("not-found", "No applied discount found with that ID"); } cart.billing = updatedCartBilling; diff --git a/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js b/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js index 3c8f502d7f1..d1e9ad69643 100644 --- a/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js +++ b/imports/node-app/plugins/discount-codes/resolvers/Mutation/removeDiscountCodeFromCart.js @@ -10,7 +10,7 @@ import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/sh * @param {Object} parentResult - unused * @param {Object} args.input - an object of all mutation arguments that were sent by the client * @param {Object} args.input.cartId - Cart to remove discount from - * @param {Object} args.input.discountCodeId - Discount code Id to remove from cart + * @param {Object} args.input.discountId - Discount code Id to remove from cart * @param {String} args.input.shopId - Shop cart belongs to * @param {String} [args.input.token] - Cart token, if anonymous * @param {String} [args.input.clientMutationId] - An optional string identifying the mutation call @@ -21,14 +21,14 @@ export default async function removeDiscountCodeFromCart(parentResult, { input } const { clientMutationId = null, cartId, - discountCodeId, + discountId, shopId, token } = input; const updatedCartWithRemovedDiscountCode = await context.mutations.removeDiscountCodeFromCart(context, { cartId: decodeCartOpaqueId(cartId), - discountCodeId: decodeDiscountOpaqueId(discountCodeId), + discountId: decodeDiscountOpaqueId(discountId), shopId: decodeShopOpaqueId(shopId), token }); diff --git a/imports/node-app/plugins/discount-codes/schemas/schema.graphql b/imports/node-app/plugins/discount-codes/schemas/schema.graphql index 17a564bbb4f..236ba3e1193 100644 --- a/imports/node-app/plugins/discount-codes/schemas/schema.graphql +++ b/imports/node-app/plugins/discount-codes/schemas/schema.graphql @@ -32,8 +32,8 @@ input RemoveDiscountCodeFromCartInput { "Cart to add discount to" cartId: ID! - "Discount code to add to cart" - discountCodeId: ID! + "ID of the discount you want to remove from the cart" + discountId: ID! "Shop cart belongs to" shopId: ID!