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
(refactor): 3662 customer product grid publishing #3876
Changes from 87 commits
168a2ed
61899c9
27ca772
3f5232c
216ba0b
f82b6e0
c38266c
5fb0f64
67f9d2c
e51ec1c
c108861
0efb321
55fef37
197fa61
a695ba1
86b50bb
ca9cb36
46bfb25
e601744
9a8dc7c
f635287
d4c2e53
a97e79a
20364fc
8f2365c
b35ca6c
bb46b62
4efae74
153d70d
1653cb6
857f23d
5ec460f
cacfbf2
57e610f
5818a96
87ab3de
16fd3f3
4aebd86
ec32b16
f3a0398
2126496
cc6a877
1cf72e8
85bb8af
dea0362
bfd2c6d
53587b7
ef50942
83d1039
dba43e2
9f8708d
70abfe8
8d5d1a5
014c009
64a8c90
0c3dc26
7eda33e
91ecd76
1809af7
75b42ac
897ff11
0f2cb3e
bcfa0cf
6ebf572
5f37644
a8f9e22
bccb156
9b9db32
ef71e6e
3355c48
49c48cb
c8abf96
bd80272
049ab53
3566522
4507843
7ed94e0
ac88682
84392af
50459ab
8d32863
e0985ff
b13d6d3
5e92a33
b70ac40
2cb852f
071d965
6fd5ef4
28e10b0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
import "./i18n"; | ||
import "./methods/catalog"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
/* eslint dot-notation: 0 */ | ||
/* eslint prefer-arrow-callback:0 */ | ||
import { Random } from "meteor/random"; | ||
import { expect } from "meteor/practicalmeteor:chai"; | ||
import { sinon } from "meteor/practicalmeteor:sinon"; | ||
import { Roles } from "meteor/alanning:roles"; | ||
import { createActiveShop } from "/server/imports/fixtures/shops"; | ||
import { Reaction } from "/server/api"; | ||
import * as Collections from "/lib/collections"; | ||
import Fixtures from "/server/imports/fixtures"; | ||
import { PublicationCollector } from "meteor/johanbrook:publication-collector"; | ||
import { RevisionApi } from "/imports/plugins/core/revisions/lib/api/revisions"; | ||
import { publishProductToCatalog } from "./catalog"; | ||
|
||
Fixtures(); | ||
|
||
describe("Catalog", function () { | ||
const shopId = Random.id(); | ||
let sandbox; | ||
|
||
beforeEach(function () { | ||
createActiveShop({ _id: shopId }); | ||
sandbox = sinon.sandbox.create(); | ||
sandbox.stub(RevisionApi, "isRevisionControlEnabled", () => true); | ||
}); | ||
|
||
afterEach(function () { | ||
sandbox.restore(); | ||
}); | ||
|
||
describe("with products", function () { | ||
const priceRangeA = { | ||
range: "1.00 - 12.99", | ||
min: 1.00, | ||
max: 12.99 | ||
}; | ||
|
||
const priceRangeB = { | ||
range: "12.99 - 19.99", | ||
min: 12.99, | ||
max: 19.99 | ||
}; | ||
|
||
beforeEach(function (done) { | ||
Collections.Products.direct.remove({}); | ||
Collections.Catalog.remove({}); | ||
|
||
// a product with price range A, and not visible | ||
const id1 = Collections.Products.insert({ | ||
ancestors: [], | ||
title: "My Little Pony", | ||
shopId, | ||
type: "simple", | ||
price: priceRangeA, | ||
isVisible: false, | ||
isLowQuantity: false, | ||
isSoldOut: false, | ||
isBackorder: false | ||
}); | ||
// a product with price range B, and visible | ||
const id2 = Collections.Products.insert({ | ||
ancestors: [], | ||
title: "Shopkins - Peachy", | ||
shopId, | ||
price: priceRangeB, | ||
type: "simple", | ||
isVisible: true, | ||
isLowQuantity: false, | ||
isSoldOut: false, | ||
isBackorder: false | ||
}); | ||
// a product with price range A, and visible | ||
const id3 = Collections.Products.insert({ | ||
ancestors: [], | ||
title: "Fresh Tomatoes", | ||
shopId, | ||
price: priceRangeA, | ||
type: "simple", | ||
isVisible: true, | ||
isLowQuantity: false, | ||
isSoldOut: false, | ||
isBackorder: false | ||
}); | ||
|
||
Promise.all([ | ||
publishProductToCatalog(id1), | ||
publishProductToCatalog(id2), | ||
publishProductToCatalog(id3) | ||
]).then(() => { | ||
done(); | ||
}); | ||
}); | ||
|
||
describe("Collection", function () { | ||
it("should return 3 products from the Catalog", function () { | ||
const products = Collections.Catalog.find({}).fetch(); | ||
expect(products.length).to.equal(3); | ||
}); | ||
}); | ||
|
||
describe("Publication", function () { | ||
it("should return 2 products from Products/get", function (done) { | ||
const productScrollLimit = 24; | ||
sandbox.stub(Reaction, "getShopId", () => shopId); | ||
sandbox.stub(Roles, "userIsInRole", () => false); | ||
|
||
const collector = new PublicationCollector({ userId: Random.id() }); | ||
let isDone = false; | ||
|
||
collector.collect("Products/grid", productScrollLimit, undefined, {}, (collections) => { | ||
const products = collections.Catalog; | ||
expect(products.length).to.equal(2); | ||
|
||
if (!isDone) { | ||
isDone = true; | ||
done(); | ||
} | ||
}); | ||
}); | ||
|
||
it("should return one product in price.min query", function (done) { | ||
const productScrollLimit = 24; | ||
const filters = { "price.min": "2.00" }; | ||
sandbox.stub(Reaction, "getShopId", () => shopId); | ||
sandbox.stub(Roles, "userIsInRole", () => false); | ||
|
||
const collector = new PublicationCollector({ userId: Random.id() }); | ||
let isDone = false; | ||
|
||
collector.collect("Products/grid", productScrollLimit, filters, {}, (collections) => { | ||
const products = collections.Catalog; | ||
expect(products.length).to.equal(1); | ||
|
||
if (!isDone) { | ||
isDone = true; | ||
done(); | ||
} | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,241 @@ | ||
import { Meteor } from "meteor/meteor"; | ||
import { check, Match } from "meteor/check"; | ||
import { Products, Catalog as CatalogCollection } from "/lib/collections"; | ||
import { Logger, Reaction } from "/server/api"; | ||
import { Media } from "/imports/plugins/core/files/server"; | ||
import { ProductRevision as Catalog } from "/imports/plugins/core/revisions/server/hooks"; | ||
|
||
/** | ||
* @method isSoldOut | ||
* @summary We are to stop accepting new orders if product is marked as `isSoldOut`. | ||
* @memberof Catalog | ||
* @param {Array} variants - Array with top-level variants | ||
* @return {Boolean} true if summary product quantity is zero. | ||
*/ | ||
export function isSoldOut(variants) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
return variants.every((variant) => { | ||
if (variant.inventoryManagement) { | ||
return Catalog.getVariantQuantity(variant) <= 0; | ||
} | ||
return false; | ||
}); | ||
} | ||
|
||
/** | ||
* @method isLowQuantity | ||
* @summary If at least one of the variants is less than the threshold, then function returns `true` | ||
* @memberof Catalog | ||
* @param {Array} variants - array of child variants | ||
* @return {boolean} low quantity or not | ||
*/ | ||
export function isLowQuantity(variants) { | ||
return variants.some((variant) => { | ||
const quantity = Catalog.getVariantQuantity(variant); | ||
// we need to keep an eye on `inventoryPolicy` too and qty > 0 | ||
if (variant.inventoryManagement && variant.inventoryPolicy && quantity) { | ||
return quantity <= variant.lowInventoryWarningThreshold; | ||
} | ||
return false; | ||
}); | ||
} | ||
|
||
/** | ||
* @method isBackorder | ||
* @summary Is products variants is still available to be ordered after summary variants quantity is zero | ||
* @memberof Catalog | ||
* @param {Array} variants - array with variant objects | ||
* @return {boolean} is backorder allowed or not for a product | ||
*/ | ||
export function isBackorder(variants) { | ||
return variants.every((variant) => !variant.inventoryPolicy && variant.inventoryManagement && | ||
variant.inventoryQuantity === 0); | ||
} | ||
|
||
/** | ||
* @method publishProductToCatalog | ||
* @summary Publish a product to the Catalog | ||
* @memberof Catalog | ||
* @param {string} productId - A string product id | ||
* @return {boolean} true on successful publish, false if publish was unsuccessful | ||
*/ | ||
export async function publishProductToCatalog(productId) { | ||
check(productId, String); | ||
|
||
// Find the product by id | ||
let product = Products.findOne({ | ||
$or: [ | ||
{ _id: productId }, | ||
{ ancestors: { $in: [productId] } } | ||
] | ||
}); | ||
|
||
// Stop if a product could not be found | ||
if (!product) { | ||
Logger.info("Cannot publish product to catalog"); | ||
return false; | ||
} | ||
|
||
// If the product has ancestors, then find to top product document | ||
if (Array.isArray(product.ancestors) && product.ancestors.length) { | ||
product = Products.findOne({ | ||
_id: product.ancestors[0] | ||
}); | ||
} | ||
|
||
// Get variants of the product | ||
const variants = Products.find({ | ||
ancestors: { | ||
$in: [productId] | ||
} | ||
}).fetch(); | ||
|
||
// Get Media for the product | ||
const mediaArray = await Media.find({ | ||
"metadata.productId": productId, | ||
"metadata.toGrid": 1, | ||
"metadata.workflow": { $nin: ["archived", "unpublished"] } | ||
}, { | ||
sort: { "metadata.priority": 1, "uploadedAt": 1 } | ||
}); | ||
|
||
// Denormalize media | ||
const productMedia = mediaArray.map((media) => ({ | ||
metadata: media.metadata, | ||
thumbnail: `${media.url({ store: "thumbnail" })}`, | ||
small: `${media.url({ store: "small" })}`, | ||
medium: `${media.url({ store: "medium" })}`, | ||
large: `${media.url({ store: "large" })}`, | ||
image: `${media.url({ store: "image" })}` | ||
})); | ||
|
||
// Denormalize product fields | ||
product.media = productMedia; | ||
product.type = "product-simple"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a new There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. For the catalog, products of type |
||
product.isSoldOut = isSoldOut(variants); | ||
product.isBackorder = isBackorder(variants); | ||
product.isLowQuantity = isLowQuantity(variants); | ||
product.variants = variants.map((variant) => { | ||
const { inventoryQuantity, ...v } = variant; | ||
return v; | ||
}); | ||
|
||
// Insert/update catalog document | ||
const result = CatalogCollection.upsert({ | ||
_id: productId | ||
}, { | ||
$set: product | ||
}); | ||
|
||
return result && result.numberAffected === 1; | ||
} | ||
|
||
/** | ||
* @method publishProductsToCatalog | ||
* @summary Publish one or more products to the Catalog | ||
* @memberof Catalog | ||
* @param {string|array} productIds - A string product id or an array of product ids | ||
* @return {boolean} true on successful publish for all documents, false if one ore more fail to publish | ||
*/ | ||
export function publishProductsToCatalog(productIds) { | ||
check(productIds, Match.OneOf(String, Array)); | ||
|
||
let ids = productIds; | ||
if (typeof ids === "string") { | ||
ids = [productIds]; | ||
} | ||
|
||
return ids.every(async (productId) => await publishProductToCatalog(productId)); | ||
} | ||
|
||
/** | ||
* @method publishProductInventoryAdjustments | ||
* @summary Publish inventory updates for a single product to the Catalog | ||
* @memberof Catalog | ||
* @param {string} productId - A string product id | ||
* @return {boolean} true on success, false on failure | ||
*/ | ||
export function publishProductInventoryAdjustments(productId) { | ||
check(productId, Match.OneOf(String, Array)); | ||
|
||
const catalogProduct = CatalogCollection.findOne({ | ||
_id: productId | ||
}); | ||
|
||
if (!catalogProduct) { | ||
Logger.info("Cannot publish inventory changes to catalog product"); | ||
return false; | ||
} | ||
|
||
const variants = Products.find({ | ||
ancestors: { | ||
$in: [productId] | ||
} | ||
}).fetch(); | ||
|
||
const update = { | ||
isSoldOut: isSoldOut(variants), | ||
isBackorder: isBackorder(variants), | ||
isLowQuantity: isLowQuantity(variants) | ||
}; | ||
|
||
// Only apply changes of one these fields have changed | ||
if ( | ||
update.isSoldOut !== catalogProduct.isSoldOut || | ||
update.isBackorder !== catalogProduct.isBackorder || | ||
update.isLowQuantity !== catalogProduct.isLowQuantity | ||
) { | ||
const result = CatalogCollection.update({ | ||
_id: productId | ||
}, { | ||
$set: update | ||
}); | ||
|
||
return result; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
Meteor.methods({ | ||
"catalog/publish/products": (productIds) => { | ||
check(productIds, Match.OneOf(String, Array)); | ||
|
||
// Ensure user has createProduct permission for active shop | ||
if (!Reaction.hasPermission("createProduct")) { | ||
Logger.error("Access Denied"); | ||
throw new Meteor.Error("access-denied", "Access Denied"); | ||
} | ||
|
||
// Convert productIds if it's a string | ||
let ids = productIds; | ||
if (typeof ids === "string") { | ||
ids = [productIds]; | ||
} | ||
|
||
// Find all products | ||
const productsToPublish = Products.find({ | ||
_id: { $in: ids } | ||
}).fetch(); | ||
|
||
if (Array.isArray(productsToPublish)) { | ||
const canUpdatePrimaryShopProducts = Reaction.hasPermission("createProduct", this.userId, Reaction.getPrimaryShopId()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a way to combine the three separate permissions checks in this method? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One call is to check for the admin of the marketplace, the other to check to see if the merchant shop user has permissions. Marketplace admins can publish/unpublish any product. |
||
|
||
const publisableProductIds = productsToPublish | ||
// Only allow users to publish products for shops they permissions to createProductsFor | ||
// If the user can createProducts on the main shop, they can publish products for all shops to the catalog. | ||
.filter((product) => Reaction.hasPermission("createProduct", this.userId, product.shopId) || canUpdatePrimaryShopProducts) | ||
.map((product) => product._id); | ||
|
||
const success = publishProductsToCatalog(publisableProductIds); | ||
|
||
if (!success) { | ||
Logger.error("Some Products could not be published to the Catalog."); | ||
throw new Meteor.Error("server-error", "Some Products could not be published to the Catalog."); | ||
} | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: The
direct
will no longer be needed after the collections hooks are removed, and this collection hook will need to be removed.