Skip to content

Commit

Permalink
Merge pull request #6089 from reactioncommerce/fix-aldeed-6077-cart-o…
Browse files Browse the repository at this point in the history
…rder-catalog-lookup

fix: find catalog product regardless of visibility
  • Loading branch information
aldeed authored Apr 13, 2020
2 parents 8e7c89b + 6f16552 commit d0c37e0
Show file tree
Hide file tree
Showing 40 changed files with 496 additions and 469 deletions.
2 changes: 2 additions & 0 deletions src/core-services/cart/mutations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +23,7 @@ export default {
reconcileCartsKeepAnonymousCart,
reconcileCartsMerge,
removeCartItems,
removeMissingItemsFromCart,
saveCart,
saveManyCarts,
setEmailOnAnonymousCart,
Expand Down
51 changes: 51 additions & 0 deletions src/core-services/cart/mutations/removeMissingItemsFromCart.js
Original file line number Diff line number Diff line change
@@ -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<undefined>} 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));
});
}
3 changes: 2 additions & 1 deletion src/core-services/cart/mutations/saveCart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion src/core-services/cart/mutations/saveManyCarts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 16 additions & 11 deletions src/core-services/cart/queries/getCommonOrderForCartGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand All @@ -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<Object>|undefined} - A CommonOrder document
*/
Expand All @@ -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);
}
2 changes: 2 additions & 0 deletions src/core-services/cart/resolvers/Cart/index.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +11,7 @@ export default {
account: resolveAccountFromAccountId,
checkout,
items,
missingItems: (cart, _, context) => xformCartItems(context, cart.missingItems || []),
shop: resolveShopFromShopId,
totalItemQuantity
};
10 changes: 10 additions & 0 deletions src/core-services/cart/schemas/cart.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/core-services/cart/simpleSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 7 additions & 92 deletions src/core-services/cart/xforms/xformCartItems.js
Original file line number Diff line number Diff line change
@@ -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
}

Expand Down
24 changes: 12 additions & 12 deletions src/core-services/catalog/mutations/hashProduct.js
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -33,7 +32,6 @@ const variantFieldsThatNeedPublishing = [
"_id",
"attributeLabel",
"barcode",
"compareAtPrice",
"height",
"index",
"isDeleted",
Expand All @@ -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) => {
Expand All @@ -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 = {};
Expand All @@ -85,27 +80,32 @@ 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);
}

/**
* @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);
if (!topLevelProduct) {
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 = {
Expand Down
Loading

0 comments on commit d0c37e0

Please sign in to comment.