Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: find catalog product regardless of visibility #6089

Merged
merged 16 commits into from
Apr 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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