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 product publications #2774

Merged
merged 15 commits into from
Sep 7, 2017
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
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,22 @@ function composer(props, onData) {
createdAt: 1
};

// Get the current user and their preferences
const user = Meteor.user();
const prefs = user && user.profile && user.profile.preferences;

// Edit mode is true by default
let editMode = true;

// if we have a "viewAs" preference and the preference is not set to "administrator", then edit mode is false
if (prefs && prefs["reaction-dashboard"] && prefs["reaction-dashboard"].viewAs) {
if (prefs["reaction-dashboard"].viewAs !== "administrator") {
editMode = false;
}
}

const queryParams = Object.assign({}, tags, Reaction.Router.current().queryParams);
const productsSubscription = Meteor.subscribe("Products", scrollLimit, queryParams, sort);
const productsSubscription = Meteor.subscribe("Products", scrollLimit, queryParams, sort, editMode);

if (productsSubscription.ready()) {
window.prerenderReady = true;
Expand Down
1 change: 1 addition & 0 deletions lib/collections/transform/cartOrder.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export const cartOrderTransform = {
const taxes = this.getTaxesByShop();
const shipping = parseFloat(this.getShippingTotal());
// no discounts right now because that will need to support multi-shop
// TODO: Build out shop-by-shop discounts and permit discounts to reduce application fee

return Object.keys(subtotals).reduce((shopTotals, shopId) => {
if (!shopTotals[shopId]) {
Expand Down
51 changes: 29 additions & 22 deletions server/api/core/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ export default {
hasPermission(checkPermissions, userId = Meteor.userId(), checkGroup = this.getShopId()) {
// check(checkPermissions, Match.OneOf(String, Array)); check(userId, String); check(checkGroup,
// Match.Optional(String));

let permissions;
// default group to the shop or global if shop isn't defined for some reason.
let group;
Expand All @@ -188,27 +187,7 @@ export default {
permissions = _.uniq(permissions);

// return if user has permissions in the group
if (Roles.userIsInRole(userId, permissions, group)) {
return true;
}

// global roles check
const sellerShopPermissions = Roles.getGroupsForUser(userId, "admin");

// we're looking for seller permissions.
if (sellerShopPermissions) {
// loop through shops roles and check permissions
for (const key in sellerShopPermissions) {
if (key) {
const shop = sellerShopPermissions[key];
if (Roles.userIsInRole(userId, permissions, shop)) {
return true;
}
}
}
}
// no specific permissions found returning false
return false;
return Roles.userIsInRole(userId, permissions, group);
},

hasOwnerAccess() {
Expand All @@ -223,6 +202,34 @@ export default {
return this.hasPermission(["owner", "admin", "dashboard"]);
},

/**
* Finds all shops that a user has a given set of roles for
* @method getShopsWithRoles
* @param {[string]} roles an array of roles to check. Will return a shopId if the user has _any_ of the roles
* @param {string} [userId=Meteor.userId()] Optional userId, defaults to Meteor.userId()
* Must pass this.userId from publications to avoid error!
* @return {[string]} Array of shopIds that the user has at least one of the given set of roles for
*/
getShopsWithRoles(roles, userId = Meteor.userId()) {
// Owner permission for a shop superceeds grantable permissions, so we always check for owner permissions as well
roles.push("owner");

// Reducer that returns a unique list of shopIds that results from calling getGroupsForUser for each role
return roles.reduce((shopIds, role) => {
// getGroupsForUser will return a list of shops for which this user has the supplied role for
const shopIdsUserHasRoleFor = Roles.getGroupsForUser(userId, role);

// If we have new shopIds found, add them to the list
if (Array.isArray(shopIdsUserHasRoleFor) && shopIdsUserHasRoleFor.length > 0) {
// Create unique array from existing shopIds array and the shops
return [...new Set([...shopIds, ...shopIdsUserHasRoleFor])];
}

// IF we don't have any shopIds returned, keep our existing list
return shopIds;
}, []);
},

getSellerShopId() {
return Roles.getGroupsForUser(this.userId, "admin");
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ describe("Publication", function () {
// setup
sandbox.stub(Reaction, "getShopId", () => shop._id);
sandbox.stub(Roles, "userIsInRole", () => true);
sandbox.stub(Reaction, "hasPermission", () => true);
sandbox.stub(Reaction, "getShopsWithRoles", () => [shop._id]);

const collector = new PublicationCollector({ userId: Random.id() });
let isDone = false;
Expand All @@ -103,6 +105,8 @@ describe("Publication", function () {
// setup
sandbox.stub(Reaction, "getShopId", () => shop._id);
sandbox.stub(Roles, "userIsInRole", () => true);
sandbox.stub(Reaction, "hasPermission", () => true);
sandbox.stub(Reaction, "getShopsWithRoles", () => [shop._id]);

const collector = new PublicationCollector({ userId: Random.id() });
let isDone = false;
Expand Down Expand Up @@ -251,6 +255,8 @@ describe("Publication", function () {
const productScrollLimit = 24;
sandbox.stub(Reaction, "getCurrentShop", function () {return { _id: "123" };});
sandbox.stub(Roles, "userIsInRole", () => true);
sandbox.stub(Reaction, "hasPermission", () => true);
sandbox.stub(Reaction, "getShopsWithRoles", () => [shop._id]);

const collector = new PublicationCollector({ userId: Random.id() });
let isDone = false;
Expand Down Expand Up @@ -330,6 +336,7 @@ describe("Publication", function () {
it("should return a product based on a regex to admin even if it isn't visible", function (done) {
sandbox.stub(Reaction, "getShopId", () => shop._id);
sandbox.stub(Roles, "userIsInRole", () => true);
sandbox.stub(Reaction, "hasPermission", () => true);

const collector = new PublicationCollector({ userId: Random.id() });
let isDone = false;
Expand Down
123 changes: 53 additions & 70 deletions server/publications/collections/product.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { Meteor } from "meteor/meteor";
import { check, Match } from "meteor/check";
import { Roles } from "meteor/alanning:roles";
import { Media, Products, Revisions } from "/lib/collections";
import { Logger, Reaction } from "/server/api";
import { RevisionApi } from "/imports/plugins/core/revisions/lib/api/revisions";


/**
* Helper function that creates and returns a Cursor of Media for relevant
* products to a publication
* @method findProductMedia
* @param {Object} publicationInstance instance of the publication that invokes this method
* @param {[String]} productIds array of productIds
* @return {Object} Media Cursor containing the product media that matches the selector
*/
export function findProductMedia(publicationInstance, productIds) {
const shopId = Reaction.getShopId();
const selector = {};

if (!shopId) {
return publicationInstance.ready();
}

if (Array.isArray(productIds)) {
selector["metadata.productId"] = {
$in: productIds
Expand All @@ -21,20 +24,22 @@ export function findProductMedia(publicationInstance, productIds) {
selector["metadata.productId"] = productIds;
}

if (shopId) {
selector["metadata.shopId"] = shopId;
}

// No one needs to see archived images on products
selector["metadata.workflow"] = {
$nin: ["archived"]
};

// Product editors can see both published and unpublished images
// There is an implied shopId in Reaction.hasPermission that defaults to
// the active shopId via Reaction.getShopId
if (!Reaction.hasPermission(["createProduct"], publicationInstance.userId)) {
selector["metadata.workflow"].$in = [null, "published"];
}


// TODO: We should differentiate between the media selector for the product grid and PDP
// The grid shouldn't need more than one Media document per product, while the PDP will need
// all the images associated with the
return Media.find(selector, {
sort: {
"metadata.priority": 1
Expand All @@ -45,69 +50,47 @@ export function findProductMedia(publicationInstance, productIds) {

/**
* product detail publication
* @param {String} productId - productId or handle
* @param {String} productIdOrHandle - productId or handle
* @return {Object} return product cursor
*/
Meteor.publish("Product", function (productId) {
check(productId, Match.OptionalOrNull(String));
if (!productId) {
Meteor.publish("Product", function (productIdOrHandle) {
check(productIdOrHandle, Match.OptionalOrNull(String));
if (!productIdOrHandle) {
Logger.debug("ignoring null request on Product subscription");
return this.ready();
}
let _id;

// REVIEW: Should we consider having an admin publication and a customer/primary shop pub?
const shopId = Reaction.getShopId();
// TODO review for REGEX / DOS vulnerabilities.
const product = Products.findOne({
$or: [{
_id: productIdOrHandle
}, {
handle: {
$regex: productIdOrHandle,
$options: "i"
}
}]
});

if (!shopId) {
if (!product) {
// Product not found, return empty subscription.
return this.ready();
}

let selector = {};
selector.isVisible = true;
selector.isDeleted = { $in: [null, false] };
const _id = product._id;

if (Roles.userIsInRole(this.userId, ["owner", "admin", "createProduct"], shopId)) {
selector.isVisible = {
$in: [true, false]
};
}
// TODO review for REGEX / DOS vulnerabilities.
if (productId.match(/^[23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz]{17}$/)) {
selector._id = productId;
// TODO try/catch here because we can have product handle passed by such regex
_id = productId;
} else {
selector.handle = {
$regex: productId,
$options: "i"
};
const products = Products.find(selector).fetch();
if (products.length > 0) {
_id = products[0]._id;
} else {
return this.ready();
}
}

// Selector for hih?
selector = {
const selector = {
isVisible: true,
isDeleted: { $in: [null, false] },
$or: [
{ handle: _id },
{ _id: _id },
{
ancestors: {
$in: [_id]
}
}
{ ancestors: _id }
]
};

// Authorized content curators for the shop get special publication of the product
// all all relevant revisions all is one package
if (Roles.userIsInRole(this.userId, ["owner", "admin", "createProduct"], shopId)) {
if (Reaction.hasPermission(["owner", "createProduct"], this.userId, product.shopId)) {
selector.isVisible = {
$in: [true, false, undefined]
};
Expand Down Expand Up @@ -156,41 +139,41 @@ Meteor.publish("Product", function (productId) {
}
}).observe({
added: (revision) => {
let product;
let observedProduct;
if (!revision.parentDocument) {
product = Products.findOne(revision.documentId);
observedProduct = Products.findOne(revision.documentId);
} else {
product = Products.findOne(revision.parentDocument);
observedProduct = Products.findOne(revision.parentDocument);
}
if (product) {
this.added("Products", product._id, product);
if (observedProduct) {
this.added("Products", observedProduct._id, observedProduct);
this.added("Revisions", revision._id, revision);
}
},
changed: (revision) => {
let product;
let observedProduct;
if (!revision.parentDocument) {
product = Products.findOne(revision.documentId);
observedProduct = Products.findOne(revision.documentId);
} else {
product = Products.findOne(revision.parentDocument);
observedProduct = Products.findOne(revision.parentDocument);
}

if (product) {
product.__revisions = [revision];
this.changed("Products", product._id, product);
if (observedProduct) {
observedProduct.__revisions = [revision];
this.changed("Products", observedProduct._id, observedProduct);
this.changed("Revisions", revision._id, revision);
}
},
removed: (revision) => {
let product;
let observedProduct;
if (!revision.parentDocument) {
product = Products.findOne(revision.documentId);
observedProduct = Products.findOne(revision.documentId);
} else {
product = Products.findOne(revision.parentDocument);
observedProduct = Products.findOne(revision.parentDocument);
}
if (product) {
product.__revisions = [];
this.changed("Products", product._id, product);
if (observedProduct) {
observedProduct.__revisions = [];
this.changed("Products", observedProduct._id, observedProduct);
this.removed("Revisions", revision._id, revision);
}
}
Expand Down
Loading