diff --git a/.meteor/versions b/.meteor/versions index aed99a91fa1..95a92a0cc10 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -13,8 +13,8 @@ allow-deny@1.1.0 audit-argument-checks@1.0.7 autoupdate@1.4.0 babel-compiler@7.0.4 -babel-runtime@1.2.0 -base64@1.0.10 +babel-runtime@1.2.2 +base64@1.0.11 binary-heap@1.0.10 blaze@2.3.2 blaze-html-templates@1.1.2 @@ -25,7 +25,7 @@ browser-policy@1.1.0 browser-policy-common@1.0.11 browser-policy-content@1.1.0 browser-policy-framing@1.1.0 -caching-compiler@1.1.9 +caching-compiler@1.1.11 caching-html-compiler@1.1.2 callback-hook@1.1.0 check@1.3.0 @@ -42,11 +42,11 @@ dispatch:run-as-user@1.1.1 dynamic-import@0.3.0 ecmascript@0.10.4 ecmascript-runtime@0.5.0 -ecmascript-runtime-client@0.6.0 +ecmascript-runtime-client@0.6.2 ecmascript-runtime-server@0.5.0 ejson@1.1.0 email@1.2.3 -es5-shim@4.7.0 +es5-shim@4.7.3 facebook-config-ui@1.0.1 facebook-oauth@1.4.0 gadicc:blaze-react-component@1.4.0 @@ -62,7 +62,7 @@ johanbrook:publication-collector@1.1.0 jquery@1.11.11 juliancwirko:postcss@1.3.0 launch-screen@1.1.1 -less@2.7.11 +less@2.7.12 livedata@1.0.18 localstorage@1.2.0 logging@1.1.19 @@ -75,18 +75,18 @@ meteorhacks:subs-manager@1.6.4 meteortesting:browser-tests@0.1.2 meteortesting:mocha@0.5.0 minifier-css@1.3.1 -minifier-js@2.3.1 +minifier-js@2.3.2 minimongo@1.4.3 mobile-experience@1.0.5 mobile-status-bar@1.0.14 -modules@0.11.3 -modules-runtime@0.9.1 +modules@0.11.4 +modules-runtime@0.9.2 momentjs:moment@2.19.4 -mongo@1.4.2 +mongo@1.4.3 mongo-dev-server@1.1.0 mongo-id@1.0.6 npm-bcrypt@0.9.3 -npm-mongo@2.2.33 +npm-mongo@2.2.34 oauth@1.2.1 oauth-encryption@1.3.0 oauth1@1.2.0 @@ -98,10 +98,10 @@ percolate:migrations@0.9.8 practicalmeteor:chai@2.1.0_1 practicalmeteor:mocha-core@1.0.1 practicalmeteor:sinon@1.14.1_2 -promise@0.10.1 +promise@0.10.2 raix:eventemitter@0.1.3 random@1.1.0 -rate-limit@1.0.8 +rate-limit@1.0.9 reactive-dict@1.2.0 reactive-var@1.0.11 reload@1.2.0 @@ -117,7 +117,7 @@ socket-stream-client@0.1.0 spacebars@1.0.15 spacebars-compiler@1.1.3 srp@1.0.10 -standard-minifier-js@2.3.1 +standard-minifier-js@2.3.2 templating@1.3.2 templating-compiler@1.3.3 templating-runtime@1.3.2 diff --git a/imports/plugins/core/catalog/server/i18n/en.json b/imports/plugins/core/catalog/server/i18n/en.json index f500e00acfd..99ed92cdbe5 100644 --- a/imports/plugins/core/catalog/server/i18n/en.json +++ b/imports/plugins/core/catalog/server/i18n/en.json @@ -4,6 +4,7 @@ "translation": { "reaction-catalog": { "admin": { + "catalogProductPublishSuccess": "Product published to catalog", "shortcut": { "catalogLabel": "Catalog", "catalogTitle": "Catalog" diff --git a/imports/plugins/core/catalog/server/index.js b/imports/plugins/core/catalog/server/index.js index 3979f964b5a..0c507c4d963 100644 --- a/imports/plugins/core/catalog/server/index.js +++ b/imports/plugins/core/catalog/server/index.js @@ -1 +1,2 @@ import "./i18n"; +import "./methods/catalog"; diff --git a/imports/plugins/core/catalog/server/methods/catalog.app-test.js b/imports/plugins/core/catalog/server/methods/catalog.app-test.js new file mode 100644 index 00000000000..26b42992ad5 --- /dev/null +++ b/imports/plugins/core/catalog/server/methods/catalog.app-test.js @@ -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(); + } + }); + }); + }); + }); +}); diff --git a/imports/plugins/core/catalog/server/methods/catalog.js b/imports/plugins/core/catalog/server/methods/catalog.js new file mode 100644 index 00000000000..4075c82e39e --- /dev/null +++ b/imports/plugins/core/catalog/server/methods/catalog.js @@ -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) { + 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"; + 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()); + + 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; + } +}); diff --git a/imports/plugins/core/revisions/client/components/publishControls.js b/imports/plugins/core/revisions/client/components/publishControls.js index 057602a039c..8381547b691 100644 --- a/imports/plugins/core/revisions/client/components/publishControls.js +++ b/imports/plugins/core/revisions/client/components/publishControls.js @@ -214,11 +214,13 @@ class PublishControls extends Component { buttonProps.i18nKeyLabel = "toolbar.publishAll"; } + const isDisabled = Array.isArray(this.props.documentIds) && this.props.documentIds.length === 0; + return (