diff --git a/src/core-services/cart/mutations/index.js b/src/core-services/cart/mutations/index.js index 86e22ec2248..373a9b93517 100644 --- a/src/core-services/cart/mutations/index.js +++ b/src/core-services/cart/mutations/index.js @@ -6,6 +6,7 @@ import reconcileCartsKeepAccountCart from "./reconcileCartsKeepAccountCart.js"; import reconcileCartsKeepAnonymousCart from "./reconcileCartsKeepAnonymousCart.js"; import reconcileCartsMerge from "./reconcileCartsMerge.js"; import removeCartItems from "./removeCartItems.js"; +import removeMissingItemsFromCart from "./removeMissingItemsFromCart.js"; import saveCart from "./saveCart.js"; import saveManyCarts from "./saveManyCarts.js"; import setEmailOnAnonymousCart from "./setEmailOnAnonymousCart.js"; @@ -22,6 +23,7 @@ export default { reconcileCartsKeepAnonymousCart, reconcileCartsMerge, removeCartItems, + removeMissingItemsFromCart, saveCart, saveManyCarts, setEmailOnAnonymousCart, diff --git a/src/core-services/cart/mutations/removeMissingItemsFromCart.js b/src/core-services/cart/mutations/removeMissingItemsFromCart.js new file mode 100644 index 00000000000..a18b9187366 --- /dev/null +++ b/src/core-services/cart/mutations/removeMissingItemsFromCart.js @@ -0,0 +1,51 @@ +/** + * @method removeMissingItemsFromCart + * @summary Checks a cart to see if any of the items in it correspond with + * product variants that are now gone (hidden or deleted). Updates the + * cart to move them to `missingItems` array and remove them from + * `items`. Mutates `cart` but doesn't save to database. + * @param {Object} context - App context + * @param {Object} cart The Cart object to check + * @returns {Promise} Nothing. Mutates `cart` object + */ +export default async function removeMissingItemsFromCart(context, cart) { + const catalogItemIds = cart.items.map((item) => item.productId); + const catalogItems = await context.collections.Catalog.find({ + "product.productId": { $in: catalogItemIds }, + "product.isDeleted": { $ne: true }, + "product.isVisible": true + }).toArray(); + + // If any items were missing from the catalog, deleted, or hidden, move them into + // a missingItems array. A product variant that has been hidden or deleted since + // being added to a cart is no longer valid as a cart item. + const items = []; + const missingItems = []; + for (const item of cart.items) { + const catalogItem = catalogItems.find((cItem) => cItem.product.productId === item.productId); + if (!catalogItem) { + missingItems.push(item); + continue; + } + const { variant: catalogVariant } = context.queries.findVariantInCatalogProduct(catalogItem.product, item.variantId); + if (!catalogVariant) { + missingItems.push(item); + continue; + } + items.push(item); + } + + if (missingItems.length === 0) return; + + cart.items = items; + cart.missingItems = missingItems; + cart.updatedAt = new Date(); + + // Usually `mutations.transformAndValidateCart` removes missing items from groups + // whenever we save a cart, but sometimes this mutation will need to be called + // when initially reading a cart, before attempting to transform it to a CommonOrder. + // So we'll also update the groups here. + cart.shipping.forEach((group) => { + group.itemIds = (group.itemIds || []).filter((itemId) => !!items.find((item) => item._id === itemId)); + }); +} diff --git a/src/core-services/cart/mutations/saveCart.js b/src/core-services/cart/mutations/saveCart.js index 5dc53563dbb..672ec16f184 100644 --- a/src/core-services/cart/mutations/saveCart.js +++ b/src/core-services/cart/mutations/saveCart.js @@ -10,7 +10,8 @@ import ReactionError from "@reactioncommerce/reaction-error"; export default async function saveCart(context, cart) { const { appEvents, collections: { Cart }, userId = null } = context; - // This will mutate `cart` + // These will mutate `cart` + await context.mutations.removeMissingItemsFromCart(context, cart); await context.mutations.transformAndValidateCart(context, cart); const { result, upsertedCount } = await Cart.replaceOne({ _id: cart._id }, cart, { upsert: true }); diff --git a/src/core-services/cart/mutations/saveManyCarts.js b/src/core-services/cart/mutations/saveManyCarts.js index e1412ffcd28..a9d0a7b162a 100644 --- a/src/core-services/cart/mutations/saveManyCarts.js +++ b/src/core-services/cart/mutations/saveManyCarts.js @@ -21,7 +21,8 @@ export default async function saveManyCarts(context, carts) { // Transform and validate each cart and then add to `bulkWrites` array const bulkWritePromises = carts.map(async (cart) => { - // Mutates `cart` + // These will mutate `cart` + await context.mutations.removeMissingItemsFromCart(context, cart); await context.mutations.transformAndValidateCart(context, cart); return { diff --git a/src/core-services/cart/queries/getCommonOrderForCartGroup.js b/src/core-services/cart/queries/getCommonOrderForCartGroup.js index 2eb94c45ea9..3c1cd08ba56 100644 --- a/src/core-services/cart/queries/getCommonOrderForCartGroup.js +++ b/src/core-services/cart/queries/getCommonOrderForCartGroup.js @@ -3,7 +3,15 @@ import ReactionError from "@reactioncommerce/reaction-error"; import xformCartGroupToCommonOrder from "../util/xformCartGroupToCommonOrder.js"; const inputSchema = new SimpleSchema({ - cartId: String, + cart: { + type: Object, + optional: true, + blackbox: true + }, + cartId: { + type: String, + optional: true + }, fulfillmentGroupId: String }); @@ -16,7 +24,9 @@ const inputSchema = new SimpleSchema({ * a CommonOrder style object * @param {Object} context - an object containing the per-request state * @param {Object} input - request parameters - * @param {String} input.cartId - cart ID to create CommonOrder from + * @param {Object} [input.cart] - Cart to create CommonOrder from. Use this instead + * of `cartId` if you have already looked up the cart. + * @param {String} [input.cartId] - cart ID to create CommonOrder from * @param {String} input.fulfillmentGroupId - fulfillment group ID to create CommonOrder from * @returns {Promise|undefined} - A CommonOrder document */ @@ -26,21 +36,16 @@ export default async function getCommonOrderForCartGroup(context, input = {}) { const { Cart } = collections; const { + cart: cartInput, cartId, fulfillmentGroupId } = input; - const cart = await Cart.findOne({ _id: cartId }); - - if (!cart) { - throw new ReactionError("not-found", "Cart not found"); - } + const cart = cartInput || (cartId && await Cart.findOne({ _id: cartId })); + if (!cart) throw new ReactionError("not-found", "Cart not found"); const group = cart.shipping.find((grp) => grp._id === fulfillmentGroupId); - - if (!group) { - throw new ReactionError("not-found", "Group not found"); - } + if (!group) throw new ReactionError("not-found", "Group not found"); return xformCartGroupToCommonOrder(cart, group, context); } diff --git a/src/core-services/cart/resolvers/Cart/index.js b/src/core-services/cart/resolvers/Cart/index.js index a8e2cd1b87f..73dbc0a45ed 100644 --- a/src/core-services/cart/resolvers/Cart/index.js +++ b/src/core-services/cart/resolvers/Cart/index.js @@ -1,6 +1,7 @@ import resolveAccountFromAccountId from "@reactioncommerce/api-utils/graphql/resolveAccountFromAccountId.js"; import resolveShopFromShopId from "@reactioncommerce/api-utils/graphql/resolveShopFromShopId.js"; import { encodeCartOpaqueId } from "../../xforms/id.js"; +import xformCartItems from "../../xforms/xformCartItems.js"; import checkout from "./checkout.js"; import items from "./items.js"; import totalItemQuantity from "./totalItemQuantity.js"; @@ -10,6 +11,7 @@ export default { account: resolveAccountFromAccountId, checkout, items, + missingItems: (cart, _, context) => xformCartItems(context, cart.missingItems || []), shop: resolveShopFromShopId, totalItemQuantity }; diff --git a/src/core-services/cart/schemas/cart.graphql b/src/core-services/cart/schemas/cart.graphql index d97422729ea..46b05c4a02d 100644 --- a/src/core-services/cart/schemas/cart.graphql +++ b/src/core-services/cart/schemas/cart.graphql @@ -49,6 +49,16 @@ type Cart implements Node { sortBy: CartItemsSortByField = addedAt ): CartItemConnection + """ + If any products or variants become hidden or are deleted after they were added to this cart, they'll be + automatically moved from `items` to `missingItems`. Clients may want to use this to show an + "items that are no longer available" list to storefront users. + + If a product becomes visible again, the item will never be automatically moved from `missingItems` + back to `items`, but clients may want to provide a way for users to manually do this. + """ + missingItems: [CartItem] + """ If you integrate with third-party systems that require you to send the same ID for order calculations as for cart calculations, you may use this ID, which is the same on a `cart` as on diff --git a/src/core-services/cart/simpleSchemas.js b/src/core-services/cart/simpleSchemas.js index 84dd19f0e1b..d8c996c33d4 100644 --- a/src/core-services/cart/simpleSchemas.js +++ b/src/core-services/cart/simpleSchemas.js @@ -849,6 +849,13 @@ export const Cart = new SimpleSchema({ "items.$": { type: CartItem }, + "missingItems": { + type: Array, + optional: true + }, + "missingItems.$": { + type: CartItem + }, "shipping": { type: Array, optional: true diff --git a/src/core-services/cart/xforms/xformCartItems.js b/src/core-services/cart/xforms/xformCartItems.js index f3edfb49fdc..432e5fe638a 100644 --- a/src/core-services/cart/xforms/xformCartItems.js +++ b/src/core-services/cart/xforms/xformCartItems.js @@ -1,103 +1,18 @@ -import ReactionError from "@reactioncommerce/reaction-error"; - -/** - * @name xformCatalogProductMedia - * @method - * @memberof GraphQL/Transforms - * @summary Transforms DB media object to final GraphQL result. Calls functions plugins have registered for type - * "xformCatalogProductMedia". First to return an object is returned here - * @param {Object} mediaItem Media item object. See ImageInfo SimpleSchema - * @param {Object} context Request context - * @returns {Object} Transformed media item - */ -async function xformCatalogProductMedia(mediaItem, context) { - const xformCatalogProductMediaFuncs = context.getFunctionsOfType("xformCatalogProductMedia"); - for (const func of xformCatalogProductMediaFuncs) { - const xformedMediaItem = await func(mediaItem, context); // eslint-disable-line no-await-in-loop - if (xformedMediaItem) { - return xformedMediaItem; - } - } - - return mediaItem; -} - -/** - * @param {Object} context - an object containing the per-request state - * @param {Object[]} catalogItems Array of CatalogItem docs from the db - * @param {Object[]} products Array of Product docs from the db - * @param {Object} cartItem CartItem - * @returns {Object} Same object with GraphQL-only props added - */ -async function xformCartItem(context, catalogItems, products, cartItem) { - const { productId, variantId } = cartItem; - - const catalogItem = catalogItems.find((cItem) => cItem.product.productId === productId); - if (!catalogItem) { - throw new ReactionError("not-found", `CatalogProduct with product ID ${productId} not found`); - } - - const catalogProduct = catalogItem.product; - - const { variant } = context.queries.findVariantInCatalogProduct(catalogProduct, variantId); - if (!variant) { - throw new ReactionError("invalid-param", `Product with ID ${productId} has no variant with ID ${variantId}`); - } - - // Find one image from the catalog to use for the item. - // Prefer the first variant image. Fallback to the first product image. - let media; - if (variant.media && variant.media.length) { - [media] = variant.media; - } else if (catalogProduct.media && catalogProduct.media.length) { - media = catalogProduct.media.find((mediaItem) => mediaItem.variantId === variantId); - if (!media) [media] = catalogProduct.media; - } - - // Allow plugins to transform the media object - if (media) { - media = await xformCatalogProductMedia(media, context); - } - - return { - ...cartItem, - imageURLs: media && media.URLs, - productConfiguration: { - productId: cartItem.productId, - productVariantId: cartItem.variantId - } - }; -} - /** * @param {Object} context - an object containing the per-request state * @param {Object[]} items Array of CartItem * @returns {Object[]} Same array with GraphQL-only props added */ export default async function xformCartItems(context, items) { - const { collections, getFunctionsOfType } = context; - const { Catalog, Products } = collections; - - const productIds = items.map((item) => item.productId); - - const catalogItems = await Catalog.find({ - "product.productId": { - $in: productIds - }, - "product.isVisible": true, - "product.isDeleted": { $ne: true }, - "isDeleted": { $ne: true } - }).toArray(); - - const products = await Products.find({ - ancestors: { - $in: productIds + const xformedItems = items.map((item) => ({ + ...item, + productConfiguration: { + productId: item.productId, + productVariantId: item.variantId } - }).toArray(); - - const xformedItems = await Promise.all(items.map((item) => xformCartItem(context, catalogItems, products, item))); + })); - for (const mutateItems of getFunctionsOfType("xformCartItems")) { + for (const mutateItems of context.getFunctionsOfType("xformCartItems")) { await mutateItems(context, xformedItems); // eslint-disable-line no-await-in-loop } diff --git a/src/core-services/catalog/mutations/hashProduct.js b/src/core-services/catalog/mutations/hashProduct.js index 4b063daf0f2..6d1af045608 100644 --- a/src/core-services/catalog/mutations/hashProduct.js +++ b/src/core-services/catalog/mutations/hashProduct.js @@ -1,6 +1,5 @@ import hash from "object-hash"; import { customPublishedProductFields, customPublishedProductVariantFields } from "../registration.js"; -import getCatalogProductMedia from "../utils/getCatalogProductMedia.js"; import getTopLevelProduct from "../utils/getTopLevelProduct.js"; const productFieldsThatNeedPublishing = [ @@ -33,7 +32,6 @@ const variantFieldsThatNeedPublishing = [ "_id", "attributeLabel", "barcode", - "compareAtPrice", "height", "index", "isDeleted", @@ -55,12 +53,12 @@ const variantFieldsThatNeedPublishing = [ * @method createProductHash * @summary Create a hash of a product to compare for updates * @memberof Catalog - * @param {String} product - The Product document to hash. Expected to be a top-level product, not a variant - * @param {Object} collections - Raw mongo collections + * @param {Object} context App context + * @param {String} product The Product document to hash. Expected to be a top-level product, not a variant * @returns {String} product hash */ -export async function createProductHash(product, collections) { - const variants = await collections.Products.find({ ancestors: product._id, type: "variant" }).toArray(); +export async function createProductHash(context, product) { + const variants = await context.collections.Products.find({ ancestors: product._id, type: "variant" }).toArray(); const productForHashing = {}; productFieldsThatNeedPublishing.forEach((field) => { @@ -70,9 +68,6 @@ export async function createProductHash(product, collections) { productForHashing[field] = product[field]; }); - // Track changes to all related media, too - productForHashing.media = await getCatalogProductMedia(product._id, collections); - // Track changes to all variants, too productForHashing.variants = variants.map((variant) => { const variantForHashing = {}; @@ -85,6 +80,10 @@ export async function createProductHash(product, collections) { return variantForHashing; }); + for (const func of context.getFunctionsOfType("mutateProductHashObject")) { + await func(context, { productForHashing, product }); // eslint-disable-line no-await-in-loop + } + return hash(productForHashing); } @@ -92,12 +91,13 @@ export async function createProductHash(product, collections) { * @method hashProduct * @summary Create a hash of a product to compare for updates * @memberof Catalog + * @param {Object} context - App context * @param {String} productId - A productId - * @param {Object} collections - Raw mongo collections * @param {Boolean} isPublished - Is product published to catalog * @returns {Object} updated product if successful, original product if unsuccessful */ -export default async function hashProduct(productId, collections, isPublished = true) { +export default async function hashProduct(context, productId, isPublished = true) { + const { collections } = context; const { Products } = collections; const topLevelProduct = await getTopLevelProduct(productId, collections); @@ -105,7 +105,7 @@ export default async function hashProduct(productId, collections, isPublished = throw new Error(`No top level product found for product with ID ${productId}`); } - const productHash = await createProductHash(topLevelProduct, collections); + const productHash = await createProductHash(context, topLevelProduct); // Insert/update product document with hash field const hashFields = { diff --git a/src/core-services/catalog/mutations/hashProduct.test.js b/src/core-services/catalog/mutations/hashProduct.test.js index d472718f63d..7fef79d6479 100644 --- a/src/core-services/catalog/mutations/hashProduct.test.js +++ b/src/core-services/catalog/mutations/hashProduct.test.js @@ -1,19 +1,12 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import { - rewire as rewire$getCatalogProductMedia, - restore as restore$getCatalogProductMedia -} from "../utils/getCatalogProductMedia.js"; import { rewire as rewire$getTopLevelProduct, restore as restore$getTopLevelProduct } from "../utils/getTopLevelProduct.js"; import hashProduct, { rewire$createProductHash, restore as restore$hashProduct } from "./hashProduct.js"; -const mockCollections = { ...mockContext.collections }; - const internalShopId = "123"; const opaqueShopId = "cmVhY3Rpb24vc2hvcDoxMjM="; // reaction/shop:123 const internalCatalogItemId = "999"; const internalProductId = "999"; const internalTagIds = ["923", "924"]; -const internalVariantIds = ["875", "874"]; const productSlug = "fake-product"; @@ -53,20 +46,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - media: [ - { - metadata: { - priority: 1, - productId: internalProductId, - variantId: null - }, - thumbnail: "http://localhost/thumbnail", - small: "http://localhost/small", - medium: "http://localhost/medium", - large: "http://localhost/large", - image: "http://localhost/original" - } - ], productId: internalProductId, productType: "productType", shop: { @@ -88,44 +67,24 @@ const mockProduct = { } }; -const mockGetCatalogProductMedia = jest - .fn() - .mockName("getCatalogProductMedia") - .mockReturnValue(Promise.resolve([ - { - priority: 1, - productId: internalProductId, - variantId: internalVariantIds[1], - URLs: { - large: "large/path/to/image.jpg", - medium: "medium/path/to/image.jpg", - original: "image/path/to/image.jpg", - small: "small/path/to/image.jpg", - thumbnail: "thumbnail/path/to/image.jpg" - } - } - ])); - const mockCreateProductHash = jest.fn().mockName("createProductHash").mockReturnValue("fake_hash"); const mockGetTopLevelProduct = jest.fn().mockName("getTopLevelProduct").mockReturnValue(mockProduct); beforeAll(() => { rewire$createProductHash(mockCreateProductHash); - rewire$getCatalogProductMedia(mockGetCatalogProductMedia); rewire$getTopLevelProduct(mockGetTopLevelProduct); }); afterAll(() => { restore$hashProduct(); - restore$getCatalogProductMedia(); restore$getTopLevelProduct(); }); test("successful update when publishing", async () => { - mockCollections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); - const updatedProduct = await hashProduct(mockProduct._id, mockCollections); + mockContext.collections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); + const updatedProduct = await hashProduct(mockContext, mockProduct._id); - expect(mockCollections.Products.updateOne).toHaveBeenCalledWith({ + expect(mockContext.collections.Products.updateOne).toHaveBeenCalledWith({ _id: mockProduct._id }, { $set: { @@ -139,10 +98,10 @@ test("successful update when publishing", async () => { }); test("when update fails, returns null", async () => { - mockCollections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 0 } })); - const updatedProduct = await hashProduct(mockProduct._id, mockCollections); + mockContext.collections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 0 } })); + const updatedProduct = await hashProduct(mockContext, mockProduct._id); - expect(mockCollections.Products.updateOne).toHaveBeenCalledWith({ + expect(mockContext.collections.Products.updateOne).toHaveBeenCalledWith({ _id: mockProduct._id }, { $set: { @@ -155,10 +114,10 @@ test("when update fails, returns null", async () => { }); test("does not update publishedProductHash when isPublished arg is false", async () => { - mockCollections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); - const updatedProduct = await hashProduct(mockProduct._id, mockCollections, false); + mockContext.collections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); + const updatedProduct = await hashProduct(mockContext, mockProduct._id, false); - expect(mockCollections.Products.updateOne).toHaveBeenCalledWith({ + expect(mockContext.collections.Products.updateOne).toHaveBeenCalledWith({ _id: mockProduct._id }, { $set: { diff --git a/src/core-services/catalog/mutations/index.js b/src/core-services/catalog/mutations/index.js index 59c53b02392..03c8dd67edb 100644 --- a/src/core-services/catalog/mutations/index.js +++ b/src/core-services/catalog/mutations/index.js @@ -1,9 +1,11 @@ import applyCustomPublisherTransforms from "./applyCustomPublisherTransforms.js"; +import hashProduct from "./hashProduct.js"; import publishProducts from "./publishProducts.js"; import partialProductPublish from "./partialProductPublish.js"; export default { applyCustomPublisherTransforms, + hashProduct, partialProductPublish, publishProducts }; diff --git a/src/core-services/catalog/mutations/publishProducts.test.js b/src/core-services/catalog/mutations/publishProducts.test.js index 9aa63e88635..b6686dfb11d 100644 --- a/src/core-services/catalog/mutations/publishProducts.test.js +++ b/src/core-services/catalog/mutations/publishProducts.test.js @@ -34,7 +34,6 @@ const mockVariants = [ _id: internalVariantIds[0], ancestors: [internalCatalogProductId], barcode: "barcode", - compareAtPrice: 0, createdAt, height: 0, index: 0, @@ -67,7 +66,6 @@ const mockVariants = [ _id: internalVariantIds[1], ancestors: [internalCatalogProductId, internalVariantIds[0]], barcode: "barcode", - compareAtPrice: 15, height: 2, index: 0, isDeleted: false, @@ -101,7 +99,6 @@ const mockProduct = { _id: internalCatalogItemId, shopId: internalShopId, barcode: "barcode", - compareAtPrice: 4.56, createdAt, description: "description", facebookMsg: "facebookMessage", diff --git a/src/core-services/catalog/queries/findCatalogProductsAndVariants.js b/src/core-services/catalog/queries/findCatalogProductsAndVariants.js index 4dc1a6aaded..f33761a47cb 100644 --- a/src/core-services/catalog/queries/findCatalogProductsAndVariants.js +++ b/src/core-services/catalog/queries/findCatalogProductsAndVariants.js @@ -11,12 +11,7 @@ export default async function findCatalogProductsAndVariants(context, variants) const { collections: { Catalog } } = context; const productIds = variants.map((variant) => variant.productId); - const catalogProductItems = await Catalog.find({ - "product.productId": { $in: productIds }, - "product.isVisible": true, - "product.isDeleted": { $ne: true }, - "isDeleted": { $ne: true } - }).toArray(); + const catalogProductItems = await Catalog.find({ "product.productId": { $in: productIds } }).toArray(); const catalogProductsAndVariants = catalogProductItems.map((catalogProductItem) => { const { product } = catalogProductItem; diff --git a/src/core-services/catalog/resolvers/CatalogProduct/index.js b/src/core-services/catalog/resolvers/CatalogProduct/index.js index f4f0f9ca4ba..45be4cf1f04 100644 --- a/src/core-services/catalog/resolvers/CatalogProduct/index.js +++ b/src/core-services/catalog/resolvers/CatalogProduct/index.js @@ -1,13 +1,10 @@ import graphqlFields from "graphql-fields"; import resolveShopFromShopId from "@reactioncommerce/api-utils/graphql/resolveShopFromShopId.js"; -import xformCatalogProductMedia from "../../utils/xformCatalogProductMedia.js"; import xformCatalogProductVariants from "../../utils/xformCatalogProductVariants.js"; import { encodeProductOpaqueId, encodeCatalogProductOpaqueId } from "../../xforms/id.js"; export default { _id: (node) => encodeCatalogProductOpaqueId(node._id), - media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context)), - primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context), productId: (node) => encodeProductOpaqueId(node.productId), shop: resolveShopFromShopId, variants: (node, args, context, info) => node.variants && xformCatalogProductVariants(context, node.variants, { diff --git a/src/core-services/catalog/resolvers/CatalogProductVariant/index.js b/src/core-services/catalog/resolvers/CatalogProductVariant/index.js index 064f4e18486..da586483257 100644 --- a/src/core-services/catalog/resolvers/CatalogProductVariant/index.js +++ b/src/core-services/catalog/resolvers/CatalogProductVariant/index.js @@ -1,11 +1,8 @@ import resolveShopFromShopId from "@reactioncommerce/api-utils/graphql/resolveShopFromShopId.js"; -import xformCatalogProductMedia from "../../utils/xformCatalogProductMedia.js"; import { encodeCatalogProductVariantOpaqueId, encodeProductOpaqueId } from "../../xforms/id.js"; export default { _id: (node) => encodeCatalogProductVariantOpaqueId(node._id), - media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context)), - primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context), shop: resolveShopFromShopId, variantId: (node) => encodeProductOpaqueId(node.variantId) }; diff --git a/src/core-services/catalog/startup.js b/src/core-services/catalog/startup.js index 2139b6805c2..2ccdf92d5b7 100644 --- a/src/core-services/catalog/startup.js +++ b/src/core-services/catalog/startup.js @@ -1,5 +1,4 @@ import Logger from "@reactioncommerce/logger"; -import hashProduct from "./mutations/hashProduct.js"; /** * @summary Called on startup @@ -8,12 +7,12 @@ import hashProduct from "./mutations/hashProduct.js"; * @returns {undefined} */ export default async function catalogStartup(context) { - const { appEvents, collections } = context; + const { appEvents, collections, mutations } = context; appEvents.on("afterMediaInsert", ({ mediaRecord }) => { const { productId } = mediaRecord.metadata || {}; if (productId) { - hashProduct(productId, collections, false).catch((error) => { + mutations.hashProduct(context, productId, false).catch((error) => { Logger.error(`Error updating currentProductHash for product with ID ${productId}`, error); }); } @@ -22,7 +21,7 @@ export default async function catalogStartup(context) { appEvents.on("afterMediaUpdate", ({ mediaRecord }) => { const { productId } = mediaRecord.metadata || {}; if (productId) { - hashProduct(productId, collections, false).catch((error) => { + mutations.hashProduct(context, productId, false).catch((error) => { Logger.error(`Error updating currentProductHash for product with ID ${productId}`, error); }); } @@ -31,7 +30,7 @@ export default async function catalogStartup(context) { appEvents.on("afterMediaRemove", ({ mediaRecord }) => { const { productId } = mediaRecord.metadata || {}; if (productId) { - hashProduct(productId, collections, false).catch((error) => { + mutations.hashProduct(context, productId, false).catch((error) => { Logger.error(`Error updating currentProductHash for product with ID ${productId}`, error); }); } @@ -49,7 +48,7 @@ export default async function catalogStartup(context) { const productOrVariantUpdateHandler = ({ productId }) => { if (productId) { - hashProduct(productId, collections, false).catch((error) => { + mutations.hashProduct(context, productId, false).catch((error) => { Logger.error(`Error updating currentProductHash for product with ID ${productId}`, error); }); } diff --git a/src/core-services/catalog/utils/createCatalogProduct.js b/src/core-services/catalog/utils/createCatalogProduct.js index adb2f0abf3c..e6ed5ac3401 100644 --- a/src/core-services/catalog/utils/createCatalogProduct.js +++ b/src/core-services/catalog/utils/createCatalogProduct.js @@ -1,17 +1,13 @@ import Logger from "@reactioncommerce/logger"; -import getCatalogProductMedia from "./getCatalogProductMedia.js"; /** * @method * @summary Converts a variant Product document into the catalog schema for variants * @param {Object} variant The variant from Products collection - * @param {Object} variantMedia Media for this specific variant * @private * @returns {Object} The transformed variant */ -export function xformVariant(variant, variantMedia) { - const primaryImage = variantMedia[0] || null; - +export function xformVariant(variant) { return { _id: variant._id, attributeLabel: variant.attributeLabel, @@ -20,12 +16,12 @@ export function xformVariant(variant, variantMedia) { height: variant.height, index: variant.index || 0, length: variant.length, - media: variantMedia, + media: variant.media || [], metafields: variant.metafields, minOrderQuantity: variant.minOrderQuantity, optionTitle: variant.optionTitle, originCountry: variant.originCountry, - primaryImage, + primaryImage: variant.primaryImage || null, shopId: variant.shopId, sku: variant.sku, title: variant.title, @@ -45,11 +41,7 @@ export function xformVariant(variant, variantMedia) { * @param {Object[]} data.variants The Product documents for all variants of this product * @returns {Object} The CatalogProduct document */ -export async function xformProduct({ context, product, variants }) { - const { collections } = context; - const catalogProductMedia = await getCatalogProductMedia(product._id, collections); - const primaryImage = catalogProductMedia[0] || null; - +export async function xformProduct({ product, variants }) { const topVariants = []; const options = new Map(); @@ -69,15 +61,11 @@ export async function xformProduct({ context, product, variants }) { const catalogProductVariants = topVariants // We want to explicitly map everything so that new properties added to variant are not published to a catalog unless we want them .map((variant) => { - const variantMedia = catalogProductMedia.filter((media) => media.variantId === variant._id); - const newVariant = xformVariant(variant, variantMedia); + const newVariant = xformVariant(variant); const variantOptions = options.get(variant._id); if (variantOptions) { - newVariant.options = variantOptions.map((option) => { - const optionMedia = catalogProductMedia.filter((media) => media.variantId === option._id); - return xformVariant(option, optionMedia); - }); + newVariant.options = variantOptions.map((option) => xformVariant(option)); } return newVariant; @@ -93,13 +81,13 @@ export async function xformProduct({ context, product, variants }) { isDeleted: !!product.isDeleted, isVisible: !!product.isVisible, length: product.length, - media: catalogProductMedia, + media: product.media || [], metafields: product.metafields, metaDescription: product.metaDescription, originCountry: product.originCountry, pageTitle: product.pageTitle, parcel: product.parcel, - primaryImage, + primaryImage: product.primaryImage || null, // The _id prop could change whereas this should always point back to the source product in Products collection productId: product._id, productType: product.productType, diff --git a/src/core-services/catalog/utils/createCatalogProduct.test.js b/src/core-services/catalog/utils/createCatalogProduct.test.js index bde2437bcca..cb34d94fb26 100644 --- a/src/core-services/catalog/utils/createCatalogProduct.test.js +++ b/src/core-services/catalog/utils/createCatalogProduct.test.js @@ -1,8 +1,4 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import { - rewire as rewire$getCatalogProductMedia, - restore as restore$getCatalogProductMedia -} from "./getCatalogProductMedia"; import createCatalogProduct, { restore as restore$createCatalogProduct, rewire$xformProduct } from "./createCatalogProduct"; const internalShopId = "123"; @@ -24,7 +20,6 @@ const mockVariants = [ ancestors: [internalCatalogProductId], barcode: "barcode", createdAt, - compareAtPrice: 1100, height: 0, index: 0, isDeleted: false, @@ -117,20 +112,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - media: [ - { - metadata: { - priority: 1, - productId: internalProductId, - variantId: null - }, - thumbnail: "http://localhost/thumbnail", - small: "http://localhost/small", - medium: "http://localhost/medium", - large: "http://localhost/large", - image: "http://localhost/original" - } - ], productId: internalProductId, productType: "productType", shop: { @@ -163,18 +144,7 @@ const mockCatalogProduct = { isDeleted: false, isVisible: false, length: 5.67, - media: [{ - URLs: { - large: "large/path/to/image.jpg", - medium: "medium/path/to/image.jpg", - original: "image/path/to/image.jpg", - small: "small/path/to/image.jpg", - thumbnail: "thumbnail/path/to/image.jpg" - }, - priority: 1, - productId: "999", - variantId: "874" - }], + media: [], metaDescription: "metaDescription", metafields: [{ description: "description", @@ -193,18 +163,7 @@ const mockCatalogProduct = { weight: 7.77, width: 5.55 }, - primaryImage: { - URLs: { - large: "large/path/to/image.jpg", - medium: "medium/path/to/image.jpg", - original: "image/path/to/image.jpg", - small: "small/path/to/image.jpg", - thumbnail: "thumbnail/path/to/image.jpg" - }, - priority: 1, - productId: "999", - variantId: "874" - }, + primaryImage: null, productId: "999", productType: "productType", shopId: "123", @@ -253,18 +212,7 @@ const mockCatalogProduct = { height: 2, index: 0, length: 2, - media: [{ - URLs: { - large: "large/path/to/image.jpg", - medium: "medium/path/to/image.jpg", - original: "image/path/to/image.jpg", - small: "small/path/to/image.jpg", - thumbnail: "thumbnail/path/to/image.jpg" - }, - priority: 1, - productId: "999", - variantId: "874" - }], + media: [], metafields: [{ description: "description", key: "key", @@ -276,18 +224,7 @@ const mockCatalogProduct = { minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - primaryImage: { - URLs: { - large: "large/path/to/image.jpg", - medium: "medium/path/to/image.jpg", - original: "image/path/to/image.jpg", - small: "small/path/to/image.jpg", - thumbnail: "thumbnail/path/to/image.jpg" - }, - priority: 1, - productId: "999", - variantId: "874" - }, + primaryImage: null, shopId: "123", sku: "sku", title: "One pound bag", @@ -312,32 +249,9 @@ const mockCatalogProduct = { }; -const mockGeCatalogProductMedia = jest - .fn() - .mockName("getCatalogProductMedia") - .mockReturnValue(Promise.resolve([ - { - priority: 1, - productId: internalProductId, - variantId: internalVariantIds[1], - URLs: { - large: "large/path/to/image.jpg", - medium: "medium/path/to/image.jpg", - original: "image/path/to/image.jpg", - small: "small/path/to/image.jpg", - thumbnail: "thumbnail/path/to/image.jpg" - } - } - ])); - mockContext.mutations.applyCustomPublisherTransforms = jest.fn().mockName("applyCustomPublisherTransforms"); -beforeAll(() => { - rewire$getCatalogProductMedia(mockGeCatalogProductMedia); -}); - afterAll(() => { - restore$getCatalogProductMedia(); restore$createCatalogProduct(); }); diff --git a/src/core-services/catalog/utils/publishProductToCatalog.js b/src/core-services/catalog/utils/publishProductToCatalog.js index fec0d30e8a1..80a9c7f5845 100644 --- a/src/core-services/catalog/utils/publishProductToCatalog.js +++ b/src/core-services/catalog/utils/publishProductToCatalog.js @@ -54,7 +54,7 @@ export default async function publishProductToCatalog(product, context) { const wasUpdateSuccessful = result && result.result && result.result.ok === 1; if (wasUpdateSuccessful) { // Update the Product hashes so that we know there are now no unpublished changes - const productHash = await createProductHash(product, context.collections); + const productHash = await createProductHash(context, product); const now = new Date(); const productUpdates = { diff --git a/src/core-services/catalog/utils/publishProductToCatalog.test.js b/src/core-services/catalog/utils/publishProductToCatalog.test.js index f5f25b2d088..19206a34b6e 100644 --- a/src/core-services/catalog/utils/publishProductToCatalog.test.js +++ b/src/core-services/catalog/utils/publishProductToCatalog.test.js @@ -1,8 +1,4 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import { - rewire as rewire$getCatalogProductMedia, - restore as restore$getCatalogProductMedia -} from "./getCatalogProductMedia.js"; import { rewire as rewire$createCatalogProduct, restore as restore$createCatalogProduct } from "./createCatalogProduct.js"; import publishProductToCatalog from "./publishProductToCatalog.js"; @@ -198,36 +194,16 @@ const mockShop = { currency: "USD" }; -const mockGeCatalogProductMedia = jest - .fn() - .mockName("getCatalogProductMedia") - .mockReturnValue(Promise.resolve([ - { - priority: 1, - productId: internalProductId, - variantId: internalVariantIds[1], - URLs: { - large: "large/path/to/image.jpg", - medium: "medium/path/to/image.jpg", - original: "image/path/to/image.jpg", - small: "small/path/to/image.jpg", - thumbnail: "thumbnail/path/to/image.jpg" - } - } - ])); - const mockCreateCatalogProduct = jest .fn() .mockName("createCatalogProduct") .mockReturnValue(mockProduct); beforeAll(() => { - rewire$getCatalogProductMedia(mockGeCatalogProductMedia); rewire$createCatalogProduct(mockCreateCatalogProduct); }); afterAll(() => { - restore$getCatalogProductMedia(); restore$createCatalogProduct(); }); diff --git a/src/core-services/files/index.js b/src/core-services/files/index.js index 514e5a00832..48df06837d5 100644 --- a/src/core-services/files/index.js +++ b/src/core-services/files/index.js @@ -3,6 +3,9 @@ import policies from "./policies.json"; import resolvers from "./resolvers/index.js"; import schemas from "./schemas/index.js"; import startup from "./startup.js"; +import mutateProductHashObjectAddMedia from "./util/mutateProductHashObject.js"; +import publishProductToCatalog from "./util/publishProductToCatalog.js"; +import xformItemsAddImageUrls from "./util/xformItemsAddImageUrls.js"; /** * @summary Import and call this function to add this plugin to your API. @@ -31,7 +34,11 @@ export default async function register(app) { } }, functionsByType: { - startup: [startup] + mutateProductHashObject: [mutateProductHashObjectAddMedia], + publishProductToCatalog: [publishProductToCatalog], + startup: [startup], + xformCartItems: [xformItemsAddImageUrls], + xformOrderItems: [xformItemsAddImageUrls] }, mutations, policies, diff --git a/src/core-services/files/resolvers/CatalogProductAndVariant.js b/src/core-services/files/resolvers/CatalogProductAndVariant.js new file mode 100644 index 00000000000..52ce32b8058 --- /dev/null +++ b/src/core-services/files/resolvers/CatalogProductAndVariant.js @@ -0,0 +1,6 @@ +import xformCatalogProductMedia from "../util/xformCatalogProductMedia.js"; + +export default { + media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context, { shopId: node.shopId })), + primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context, { shopId: node.shopId }) +}; diff --git a/src/core-services/files/resolvers/index.js b/src/core-services/files/resolvers/index.js index 6b9c90688a3..1861112449d 100644 --- a/src/core-services/files/resolvers/index.js +++ b/src/core-services/files/resolvers/index.js @@ -1,5 +1,8 @@ +import CatalogProductAndVariant from "./CatalogProductAndVariant.js"; import Mutation from "./Mutation/index.js"; export default { + CatalogProduct: CatalogProductAndVariant, + CatalogProductVariant: CatalogProductAndVariant, Mutation }; diff --git a/src/core-services/catalog/utils/getCatalogProductMedia.js b/src/core-services/files/util/getCatalogProductMedia.js similarity index 100% rename from src/core-services/catalog/utils/getCatalogProductMedia.js rename to src/core-services/files/util/getCatalogProductMedia.js diff --git a/src/core-services/catalog/utils/getCatalogProductMedia.test.js b/src/core-services/files/util/getCatalogProductMedia.test.js similarity index 100% rename from src/core-services/catalog/utils/getCatalogProductMedia.test.js rename to src/core-services/files/util/getCatalogProductMedia.test.js diff --git a/src/core-services/files/util/mutateProductHashObject.js b/src/core-services/files/util/mutateProductHashObject.js new file mode 100644 index 00000000000..b4be741e30f --- /dev/null +++ b/src/core-services/files/util/mutateProductHashObject.js @@ -0,0 +1,15 @@ +import getCatalogProductMedia from "./getCatalogProductMedia.js"; + +/** + * @summary Adds media property to the product object before it is hashed. + * This makes media changes result in a product being marked as in need + * of republishing. + * @param {Object} context App context + * @param {Object} info Info object + * @param {Object} info.productForHashing Product object that will be hashed and may be mutated. + * @param {Object} info.product The full Product entity + * @return {Promise} Mutates but does not return anything + */ +export default async function mutateProductHashObjectAddMedia(context, { productForHashing, product }) { + productForHashing.media = await getCatalogProductMedia(product._id, context.collections); +} diff --git a/src/core-services/files/util/publishProductToCatalog.js b/src/core-services/files/util/publishProductToCatalog.js new file mode 100644 index 00000000000..fed3cb7991b --- /dev/null +++ b/src/core-services/files/util/publishProductToCatalog.js @@ -0,0 +1,32 @@ +import getCatalogProductMedia from "./getCatalogProductMedia.js"; + +/** + * @name publishProductToCatalogMedia + * @method + * @memberof GraphQL/Transforms + * @summary Transforms a CatalogProduct before saving it + * @param {Object} catalogProduct CatalogProduct object to potentially mutate + * @param {Object} info Additional info + * @param {Object} info.context App context + * @returns {Promise} Mutates `catalogProduct` but does not return anything + */ +export default async function publishProductToCatalogMedia(catalogProduct, { context }) { + const catalogProductMedia = await getCatalogProductMedia(catalogProduct.productId, context.collections); + + catalogProduct.media = catalogProductMedia; + catalogProduct.primaryImage = catalogProductMedia[0] || null; + + for (const catalogProductVariant of catalogProduct.variants) { + const variantMedia = catalogProductMedia.filter((media) => media.variantId === catalogProductVariant.variantId); + catalogProductVariant.media = variantMedia; + catalogProductVariant.primaryImage = variantMedia[0] || null; + + if (catalogProductVariant.options) { + for (const catalogProductOption of catalogProductVariant.options) { + const optionMedia = catalogProductMedia.filter((media) => media.variantId === catalogProductOption.variantId); + catalogProductVariant.media = optionMedia; + catalogProductVariant.primaryImage = optionMedia[0] || null; + } + } + } +} diff --git a/src/core-services/catalog/utils/xformCatalogProductMedia.js b/src/core-services/files/util/xformCatalogProductMedia.js similarity index 82% rename from src/core-services/catalog/utils/xformCatalogProductMedia.js rename to src/core-services/files/util/xformCatalogProductMedia.js index 8facbb385c4..f28adcce39c 100644 --- a/src/core-services/catalog/utils/xformCatalogProductMedia.js +++ b/src/core-services/files/util/xformCatalogProductMedia.js @@ -23,12 +23,14 @@ function ensureAbsoluteUrls(context, mediaItem) { * "xformCatalogProductMedia". First to return an object is returned here * @param {Object} mediaItem Media item object. See ImageInfo SimpleSchema * @param {Object} context Request context + * @param {Object} info Other info about the media + * @param {Object} info.shopId ID of the shop that owns the catalog product * @returns {Object} Transformed media item */ -export default async function xformCatalogProductMedia(mediaItem, context) { +export default async function xformCatalogProductMedia(mediaItem, context, { shopId }) { const xformCatalogProductMediaFuncs = context.getFunctionsOfType("xformCatalogProductMedia"); for (const func of xformCatalogProductMediaFuncs) { - const xformedMediaItem = await func(mediaItem, context); // eslint-disable-line no-await-in-loop + const xformedMediaItem = await func(mediaItem, context, { shopId }); // eslint-disable-line no-await-in-loop if (xformedMediaItem) { return ensureAbsoluteUrls(context, xformedMediaItem); } diff --git a/src/core-services/files/util/xformItemsAddImageUrls.js b/src/core-services/files/util/xformItemsAddImageUrls.js new file mode 100644 index 00000000000..76bf0eb34f3 --- /dev/null +++ b/src/core-services/files/util/xformItemsAddImageUrls.js @@ -0,0 +1,34 @@ +import getCatalogProductMedia from "./getCatalogProductMedia.js"; +import xformCatalogProductMedia from "./xformCatalogProductMedia.js"; + +/** + * @summary Mutates an array of CartItem or OrderItem to add image fields at read time + * @param {Object} context App context + * @param {Object[]} items An array of CartItem or OrderItem objects + * @param {Object} info Additional info + * @returns {undefined} Returns nothing. Potentially mutates `items` + */ +export default async function xformItemsAddImageUrls(context, items) { + if (items.length === 0) return; + + for (const item of items) { + const { productId, shopId, variantId } = item; + + const catalogProductMedia = await getCatalogProductMedia(productId, context.collections); // eslint-disable-line no-await-in-loop + + // Find one image from the catalog to use for the item. + // Prefer the first variant image. Fallback to the first product image. + let media; + if (catalogProductMedia && catalogProductMedia.length) { + media = catalogProductMedia.find((mediaItem) => mediaItem.variantId === variantId); + if (!media) [media] = catalogProductMedia; + } + + // Allow plugins to transform the media object + if (media) { + media = await xformCatalogProductMedia(media, context, { shopId }); // eslint-disable-line no-await-in-loop + } + + item.imageURLs = (media && media.URLs) || null; + } +} diff --git a/src/core-services/orders/util/getDataForOrderEmail.test.js b/src/core-services/orders/util/getDataForOrderEmail.test.js index 7cf7b8d29a7..fcc1aeb8c8f 100644 --- a/src/core-services/orders/util/getDataForOrderEmail.test.js +++ b/src/core-services/orders/util/getDataForOrderEmail.test.js @@ -111,13 +111,6 @@ test("returns expected data structure (base case)", async () => { combinedItems: [ { ...mockOrder.shipping[0].items[0], - imageURLs: { - large: "large.jpg", - medium: "medium.jpg", - original: "original.jpg", - small: "small.jpg", - thumbnail: "thumbnail.jpg" - }, placeholderImage: "https://app.mock/resources/placeholder.gif", price: { amount: jasmine.any(Number), @@ -128,8 +121,6 @@ test("returns expected data structure (base case)", async () => { productId: "mockProductId", productVariantId: "mockVariantId" }, - productImage: "large.jpg", - variantImage: "large.jpg", subtotal: { amount: jasmine.any(Number), currencyCode: "mockCurrencyCode", @@ -149,13 +140,6 @@ test("returns expected data structure (base case)", async () => { items: [ { ...mockOrder.shipping[0].items[0], - imageURLs: { - large: "large.jpg", - medium: "medium.jpg", - original: "original.jpg", - small: "small.jpg", - thumbnail: "thumbnail.jpg" - }, placeholderImage: "https://app.mock/resources/placeholder.gif", price: { amount: jasmine.any(Number), @@ -166,8 +150,6 @@ test("returns expected data structure (base case)", async () => { productId: "mockProductId", productVariantId: "mockVariantId" }, - productImage: "large.jpg", - variantImage: "large.jpg", subtotal: { amount: jasmine.any(Number), currencyCode: "mockCurrencyCode", diff --git a/src/core-services/orders/xforms/xformOrderItems.js b/src/core-services/orders/xforms/xformOrderItems.js index f28820c095a..8c5971f1dd4 100644 --- a/src/core-services/orders/xforms/xformOrderItems.js +++ b/src/core-services/orders/xforms/xformOrderItems.js @@ -1,67 +1,11 @@ -import ReactionError from "@reactioncommerce/reaction-error"; - -/** - * @name xformCatalogProductMedia - * @method - * @memberof GraphQL/Transforms - * @summary Transforms DB media object to final GraphQL result. Calls functions plugins have registered for type - * "xformCatalogProductMedia". First to return an object is returned here - * @param {Object} mediaItem Media item object. See ImageInfo SimpleSchema - * @param {Object} context Request context - * @returns {Object} Transformed media item - */ -async function xformCatalogProductMedia(mediaItem, context) { - const xformCatalogProductMediaFuncs = context.getFunctionsOfType("xformCatalogProductMedia"); - for (const func of xformCatalogProductMediaFuncs) { - const xformedMediaItem = await func(mediaItem, context); // eslint-disable-line no-await-in-loop - if (xformedMediaItem) { - return xformedMediaItem; - } - } - - return mediaItem; -} - /** * @param {Object} context - an object containing the per-request state - * @param {Object} item The order fulfillment group item in DB format - * @param {Object[]} catalogItems Array of CatalogItem docs from the db - * @param {Object[]} products Array of Product docs from the db - * @returns {Object} Same object with GraphQL-only props added + * @param {Object[]} items Array of order fulfillment group items + * @returns {Object[]} Same array with GraphQL-only props added */ -async function xformOrderItem(context, item, catalogItems) { - const { productId, variantId } = item; - - const catalogItem = catalogItems.find((cItem) => cItem.product.productId === productId); - if (!catalogItem) { - throw new ReactionError("not-found", `CatalogProduct with product ID ${productId} not found`); - } - - const catalogProduct = catalogItem.product; - - const { variant } = context.queries.findVariantInCatalogProduct(catalogProduct, variantId); - if (!variant) { - throw new ReactionError("invalid-param", `Product with ID ${productId} has no variant with ID ${variantId}`); - } - - // Find one image from the catalog to use for the item. - // Prefer the first variant image. Fallback to the first product image. - let media; - if (variant.media && variant.media.length) { - [media] = variant.media; - } else if (catalogProduct.media && catalogProduct.media.length) { - media = catalogProduct.media.find((mediaItem) => mediaItem.variantId === variantId); - if (!media) [media] = catalogProduct.media; - } - - // Allow plugins to transform the media object - if (media) { - media = await xformCatalogProductMedia(media, context); - } - - return { +export default async function xformOrderItems(context, items) { + const xformedItems = items.map((item) => ({ ...item, - imageURLs: media && media.URLs, productConfiguration: { productId: item.productId, productVariantId: item.variantId @@ -70,28 +14,11 @@ async function xformOrderItem(context, item, catalogItems) { amount: item.subtotal, currencyCode: item.price.currencyCode } - }; -} - -/** - * @param {Object} context - an object containing the per-request state - * @param {Object[]} items Array of order fulfillment group items - * @returns {Object[]} Same array with GraphQL-only props added - */ -export default async function xformOrderItems(context, items) { - const { collections } = context; - const { Catalog } = collections; - - const productIds = items.map((item) => item.productId); + })); - const catalogItems = await Catalog.find({ - "product.productId": { - $in: productIds - }, - "product.isVisible": true, - "product.isDeleted": { $ne: true }, - "isDeleted": { $ne: true } - }).toArray(); + for (const mutateItems of context.getFunctionsOfType("xformOrderItems")) { + await mutateItems(context, xformedItems); // eslint-disable-line no-await-in-loop + } - return Promise.all(items.map((item) => xformOrderItem(context, item, catalogItems))); + return xformedItems; } diff --git a/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.js b/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.js index e043a15c5b8..c3e19de3b02 100644 --- a/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.js +++ b/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.js @@ -67,10 +67,15 @@ export default async function updateFulfillmentOptionsForGroup(context, input) { const cart = await getCartById(context, cartId, { cartToken, throwIfNotFound: true }); + // This is done by `saveCart`, too, but we need to do it before every call to `getCommonOrderForCartGroup` + // to avoid errors in the case where a product has been deleted since the last time this cart was saved. + // This mutates that `cart` object. + await context.mutations.removeMissingItemsFromCart(context, cart); + const fulfillmentGroup = (cart.shipping || []).find((group) => group._id === fulfillmentGroupId); if (!fulfillmentGroup) throw new ReactionError("not-found", `Fulfillment group with ID ${fulfillmentGroupId} not found in cart with ID ${cartId}`); - const commonOrder = await context.queries.getCommonOrderForCartGroup(context, { cartId: cart._id, fulfillmentGroupId: fulfillmentGroup._id }); + const commonOrder = await context.queries.getCommonOrderForCartGroup(context, { cart, fulfillmentGroupId: fulfillmentGroup._id }); // In the future we want to do this async and subscribe to the results const rates = await context.queries.getFulfillmentMethodsWithQuotes(commonOrder, context); diff --git a/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.test.js b/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.test.js index 77c58e1faa5..c34f49cbcaa 100644 --- a/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.test.js +++ b/src/core-services/shipping/mutations/updateFulfillmentOptionsForGroup.test.js @@ -36,6 +36,9 @@ beforeAll(() => { if (!mockContext.mutations.saveCart) { mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); } + if (!mockContext.mutations.removeMissingItemsFromCart) { + mockContext.mutations.removeMissingItemsFromCart = jest.fn().mockName("context.mutations.removeMissingItemsFromCart"); + } }); beforeEach(() => { diff --git a/src/plugins/simple-pricing/index.js b/src/plugins/simple-pricing/index.js index 7694437f69b..e4f0a8233aa 100644 --- a/src/plugins/simple-pricing/index.js +++ b/src/plugins/simple-pricing/index.js @@ -40,7 +40,7 @@ export default async function register(app) { queries, catalog: { publishedProductFields: ["price"], - publishedProductVariantFields: ["price"] + publishedProductVariantFields: ["compareAtPrice", "price"] }, simpleSchemas: { PriceRange diff --git a/tests/integration/api/queries/anonymousCartByCartId/AnonymousCartByCartIdQuery.graphql b/tests/integration/api/queries/anonymousCartByCartId/AnonymousCartByCartIdQuery.graphql index d60e6a2b04b..4708adc87c8 100644 --- a/tests/integration/api/queries/anonymousCartByCartId/AnonymousCartByCartIdQuery.graphql +++ b/tests/integration/api/queries/anonymousCartByCartId/AnonymousCartByCartIdQuery.graphql @@ -2,6 +2,70 @@ query($cartId: ID!, $cartToken: String!){ anonymousCartByCartId(cartId: $cartId, cartToken: $cartToken){ _id items { + nodes { + _id + productConfiguration { + productId + productVariantId + } + addedAt + attributes { + label + value + } + createdAt + isBackorder + isLowQuantity + isSoldOut + imageURLs { + large + small + original + medium + thumbnail + } + metafields { + value + key + } + parcel { + length + width + weight + height + } + price { + amount + displayAmount + currency { + code + } + } + priceWhenAdded { + amount + displayAmount + currency { + code + } + } + productSlug + productType + quantity + subtotal { + displayAmount + } + title + productTags { + nodes { + name + } + } + productVendor + variantTitle + optionTitle + updatedAt + inventoryAvailableToSell + } totalCount } } diff --git a/tests/integration/api/queries/anonymousCartByCartId/addCartItemsMutation.graphql b/tests/integration/api/queries/anonymousCartByCartId/addCartItemsMutation.graphql new file mode 100644 index 00000000000..49cb2b9f88f --- /dev/null +++ b/tests/integration/api/queries/anonymousCartByCartId/addCartItemsMutation.graphql @@ -0,0 +1,9 @@ +mutation addCartItems( $cartId: ID!, $items: [CartItemInput]!, $cartToken: String ) { + addCartItems( input: {cartId: $cartId, items: $items, cartToken: $cartToken} ){ + cart { + items { + totalCount + } + } + } +} diff --git a/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js b/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js index aeb9004cc92..5b0466a3d66 100644 --- a/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js +++ b/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js @@ -5,14 +5,17 @@ import Factory from "/tests/util/factory.js"; import TestApp from "/tests/util/TestApp.js"; const AnonymousCartByCartIdQuery = importAsString("./AnonymousCartByCartIdQuery.graphql"); +const addCartItemsMutation = importAsString("./addCartItemsMutation.graphql"); jest.setTimeout(300000); +let addCartItems; let anonymousCartByCartId; let mockCart; let opaqueCartId; let shopId; let testApp; +let testCatalogItemId; const cartToken = "TOKEN"; beforeAll(async () => { @@ -20,6 +23,7 @@ beforeAll(async () => { await testApp.start(); shopId = await testApp.insertPrimaryShop(); anonymousCartByCartId = testApp.query(AnonymousCartByCartIdQuery); + addCartItems = testApp.mutate(addCartItemsMutation); // create mock cart mockCart = Factory.Cart.makeOne({ @@ -50,3 +54,147 @@ test("an anonymous user can retrieve their cart", async () => { expect(result.anonymousCartByCartId._id).toEqual(opaqueCartId); }); + +test("anonymous cart query works after a related catalog product is hidden", async () => { + // create mock product + const catalogItem = Factory.Catalog.makeOne({ + isDeleted: false, + isVisible: true, + product: Factory.CatalogProduct.makeOne({ + productId: "1", + isDeleted: false, + isVisible: true, + variants: Factory.CatalogProductVariant.makeMany(1, { + variantId: "v1", + options: null, + pricing: { + USD: { + compareAtPrice: 109.99, + displayPrice: "$99.99 - $105.99", + maxPrice: 105.99, + minPrice: 99.99, + price: 99.99 + } + } + }) + }) + }); + + const { insertedId } = await testApp.collections.Catalog.insertOne(catalogItem); + + const items = [{ + price: { + amount: 99.99, + currencyCode: "USD" + }, + productConfiguration: { + productId: encodeOpaqueId("reaction/product", catalogItem.product.productId), + productVariantId: encodeOpaqueId("reaction/product", catalogItem.product.variants[0].variantId) + }, + quantity: 1 + }]; + + let result; + try { + await addCartItems({ cartId: opaqueCartId, items, cartToken }); + + await testApp.collections.Catalog.updateOne({ + _id: insertedId + }, { + $set: { + "product.isVisible": false + } + }); + + result = await anonymousCartByCartId({ cartId: opaqueCartId, cartToken }); + } catch (error) { + expect(error).toBeUndefined(); + return; + } + + expect(result.anonymousCartByCartId._id).toBe(opaqueCartId); + expect(result.anonymousCartByCartId.items.nodes.length).toBe(1); +}); + +test("anonymous cart query works after a related catalog product is deleted", async () => { + // create mock product + const catalogItem = Factory.Catalog.makeOne({ + isDeleted: false, + isVisible: true, + product: Factory.CatalogProduct.makeOne({ + productId: "2", + isDeleted: false, + isVisible: true, + variants: Factory.CatalogProductVariant.makeMany(1, { + variantId: "v2", + options: null, + pricing: { + USD: { + compareAtPrice: 109.99, + displayPrice: "$99.99 - $105.99", + maxPrice: 105.99, + minPrice: 99.99, + price: 99.99 + } + } + }) + }) + }); + + const { insertedId } = await testApp.collections.Catalog.insertOne(catalogItem); + testCatalogItemId = insertedId; + + const items = [{ + price: { + amount: 99.99, + currencyCode: "USD" + }, + productConfiguration: { + productId: encodeOpaqueId("reaction/product", catalogItem.product.productId), + productVariantId: encodeOpaqueId("reaction/product", catalogItem.product.variants[0].variantId) + }, + quantity: 1 + }]; + + let result; + try { + await addCartItems({ cartId: opaqueCartId, items, cartToken }); + + await testApp.collections.Catalog.updateOne({ + _id: insertedId + }, { + $set: { + "product.isDeleted": true + } + }); + + result = await anonymousCartByCartId({ cartId: opaqueCartId, cartToken }); + } catch (error) { + expect(error).toBeUndefined(); + return; + } + + expect(result.anonymousCartByCartId._id).toBe(opaqueCartId); + expect(result.anonymousCartByCartId.items.nodes.length).toBe(1); +}); + +test("anonymous cart query works after a related catalog product variant is deleted or hidden", async () => { + let result; + try { + await testApp.collections.Catalog.updateOne({ + _id: testCatalogItemId + }, { + $set: { + "product.variants": [] + } + }); + + result = await anonymousCartByCartId({ cartId: opaqueCartId, cartToken }); + } catch (error) { + expect(error).toBeUndefined(); + return; + } + + expect(result.anonymousCartByCartId._id).toBe(opaqueCartId); + expect(result.anonymousCartByCartId.items.nodes.length).toBe(1); +}); diff --git a/tests/integration/api/queries/catalogItemProduct/catalogItemProduct.test.js b/tests/integration/api/queries/catalogItemProduct/catalogItemProduct.test.js index f32586ae978..c13952bc002 100644 --- a/tests/integration/api/queries/catalogItemProduct/catalogItemProduct.test.js +++ b/tests/integration/api/queries/catalogItemProduct/catalogItemProduct.test.js @@ -117,11 +117,11 @@ test("get a catalog product by slug", async () => { productId: result.catalogItemProduct.product.productId, variantId: null, URLs: { - thumbnail: "https://shop.fake.site/thumbnail", - small: "https://shop.fake.site/small", - medium: "https://shop.fake.site/medium", - large: "https://shop.fake.site/large", - original: "https://shop.fake.site/original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } } ]); @@ -150,11 +150,11 @@ test("get a catalog product by ID", async () => { { productId: result.catalogItemProduct.product.productId, URLs: { - thumbnail: "https://shop.fake.site/thumbnail", - small: "https://shop.fake.site/small", - medium: "https://shop.fake.site/medium", - large: "https://shop.fake.site/large", - original: "https://shop.fake.site/original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } } ]); diff --git a/tests/mocks/mockCatalogItems.js b/tests/mocks/mockCatalogItems.js index 2b95389bc9c..ef6f6904eca 100644 --- a/tests/mocks/mockCatalogItems.js +++ b/tests/mocks/mockCatalogItems.js @@ -1,4 +1,3 @@ -import { cloneDeep } from "lodash"; import { internalShopId } from "./mockShop"; import { internalCatalogItemIds, @@ -34,39 +33,12 @@ export const mockCatalogItems = [ } ]; -/** - * Mock absolute URLs in catalog products when returned from GraphQL - */ -export const mockExternalCatalogProductNodes = []; -const siteURL = "https://shop.fake.site"; - -function mockMediaURLsResponse(URLs) { - const { large, medium, original, small, thumbnail } = URLs; - return { - thumbnail: `${siteURL}${thumbnail}`, - small: `${siteURL}${small}`, - medium: `${siteURL}${medium}`, - large: `${siteURL}${large}`, - original: `${siteURL}${original}` - }; -} - -mockExternalCatalogProducts.forEach((mockExternalCatalogProduct) => { - const cloned = cloneDeep(mockExternalCatalogProduct); - cloned.product.primaryImage.URLs = mockMediaURLsResponse(cloned.product.primaryImage.URLs); - cloned.product.media.forEach((media) => { - media.URLs = mockMediaURLsResponse(media.URLs); - }); - - mockExternalCatalogProductNodes.push(cloned); -}); - /** * mock unsorted catalogItems query response */ export const mockUnsortedCatalogItemsResponse = { catalogItems: { - nodes: mockExternalCatalogProductNodes + nodes: mockExternalCatalogProducts } }; @@ -76,7 +48,7 @@ export const mockUnsortedCatalogItemsResponse = { */ export const mockSortedByPriceHigh2LowCatalogItemsResponse = { catalogItems: { - nodes: [mockExternalCatalogProductNodes[1], mockExternalCatalogProductNodes[0]] + nodes: [mockExternalCatalogProducts[1], mockExternalCatalogProducts[0]] } }; @@ -85,6 +57,6 @@ export const mockSortedByPriceHigh2LowCatalogItemsResponse = { */ export const mockSortedByPriceLow2HighCatalogItemsResponse = { catalogItems: { - nodes: [mockExternalCatalogProductNodes[0], mockExternalCatalogProductNodes[1]] + nodes: [mockExternalCatalogProducts[0], mockExternalCatalogProducts[1]] } };