From f1a28458702e033e0125021b3a1a595528570c17 Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Sun, 3 May 2020 10:54:29 +0200 Subject: [PATCH 01/11] feat: add invitations query and types to schema Signed-off-by: Loan Laux --- src/schemas/inviteShopMember.graphql | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/schemas/inviteShopMember.graphql b/src/schemas/inviteShopMember.graphql index 93a17ef..758a750 100644 --- a/src/schemas/inviteShopMember.graphql +++ b/src/schemas/inviteShopMember.graphql @@ -25,6 +25,55 @@ type InviteShopMemberPayload { clientMutationId: String } +""" +Wraps a list of `Invitation`s, providing pagination cursors and information. + +For information about what Relay-compatible connections are and how to use them, see the following articles: +- [Relay Connection Documentation](https://facebook.github.io/relay/docs/en/graphql-server-specification.html#connections) +- [Relay Connection Specification](https://facebook.github.io/relay/graphql/connections.htm) +- [Using Relay-style Connections With Apollo Client](https://www.apollographql.com/docs/react/recipes/pagination.html) +""" +type InvitationConnection { + "The list of nodes that match the query, wrapped in an edge to provide a cursor string for each" + edges: [InvitationEdge] + + """ + You can request the `nodes` directly to avoid the extra wrapping that `NodeEdge` has, + if you know you will not need to paginate the results. + """ + nodes: [Invitation] + "Information to help a client request the next or previous page" + pageInfo: PageInfo! + "The total number of nodes that match your query" + totalCount: Int! +} + +"A connection edge in which each node is an `Invitation` object" +type InvitationEdge implements NodeEdge { + "The cursor that represents this node in the paginated results" + cursor: ConnectionCursor! + "The account" + node: Invitation +} + +"Represents a single staff member invitation" +type Invitation implements Node { + "The invitation ID" + _id: ID! + + "The e-mail address the invitation was sent to" + email: String! + + "The groups this person was invited to" + groups: [Group]! + + "The shop this person was invited to" + shop: Shop! + + "The admin who invited this person" + invitedBy: Account +} + extend type Mutation { """ Given a person's email address and name, invite them to create an account for a certain shop, @@ -35,3 +84,29 @@ extend type Mutation { input: InviteShopMemberInput! ): InviteShopMemberPayload } + +extend type Query { + "Returns all pending staff member invitations" + invitations( + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor, + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor, + + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." + first: ConnectionLimitInt, + + "Return at most this many results. This parameter may be used with the `before` parameter." + last: ConnectionLimitInt, + + "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." + offset: Int, + + "Return results sorted in this order" + sortOrder: SortOrder = asc, + + "By default, groups are sorted by when they were created, oldest first. Set this to sort by one of the other allowed fields" + sortBy: AccountSortByField = createdAt + ): InvitationConnection! +} From 77778e925ecf737e287fb20368fd64e117d9dd5a Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Sun, 3 May 2020 11:57:44 +0200 Subject: [PATCH 02/11] feat: add aggregate and query resolver Signed-off-by: Loan Laux --- src/queries/index.js | 2 + src/queries/invitationsAggregate.js | 62 ++++++++++++++++++++++++++++ src/resolvers/Query/index.js | 2 + src/resolvers/Query/invitations.js | 24 +++++++++++ src/schemas/inviteShopMember.graphql | 6 --- 5 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 src/queries/invitationsAggregate.js create mode 100644 src/resolvers/Query/invitations.js diff --git a/src/queries/index.js b/src/queries/index.js index 9dc9ca3..217ce9b 100644 --- a/src/queries/index.js +++ b/src/queries/index.js @@ -2,6 +2,7 @@ import accounts from "./accounts.js"; import group from "./group.js"; import groups from "./groups.js"; import groupsByAccount from "./groupsByAccount.js"; +import invitationsAggregate from "./invitationsAggregate.js"; import userAccount from "./userAccount.js"; export default { @@ -9,5 +10,6 @@ export default { group, groups, groupsByAccount, + invitationsAggregate, userAccount }; diff --git a/src/queries/invitationsAggregate.js b/src/queries/invitationsAggregate.js new file mode 100644 index 0000000..d3672e7 --- /dev/null +++ b/src/queries/invitationsAggregate.js @@ -0,0 +1,62 @@ +/** + * @name accounts + * @method + * @memberof Accounts/NoMeteorQueries + * @summary Returns accounts optionally filtered by group IDs + * @param {Object} context - an object containing the per-request state + * @param {String} input - input for query + * @param {String} [input.groupIds] - Array of group IDs to limit the results + * @returns {Promise} Mongo cursor + */ +export default async function accounts(context, input) { + const { collections } = context; + const { AccountInvites } = collections; + + await context.validatePermissions("reaction:legacy:accounts", "read"); + + return { + collection: AccountInvites, + pipeline: [ + { + $lookup: { + from: "Shops", + localField: "shopId", + foreignField: "_id", + as: "shop" + } + }, + { + $unwind: { + path: "$shop" + } + }, + { + $lookup: { + from: "Groups", + localField: "groupIds", + foreignField: "_id", + as: "groups" + } + }, + { + $lookup: { + from: "Accounts", + localField: "invitedByUserId", + foreignField: "userId", + as: "invitedBy" + } + }, + { + $unwind: { + path: "$invitedBy" + } + }, + { + $project: { + groupIds: 0, + invitedByUserId: 0 + } + } + ] + }; +} diff --git a/src/resolvers/Query/index.js b/src/resolvers/Query/index.js index ee0fa56..b319021 100644 --- a/src/resolvers/Query/index.js +++ b/src/resolvers/Query/index.js @@ -2,6 +2,7 @@ import account from "./account.js"; import accounts from "./accounts.js"; import group from "./group.js"; import groups from "./groups.js"; +import invitations from "./invitations.js"; import viewer from "./viewer.js"; export default { @@ -9,5 +10,6 @@ export default { accounts, group, groups, + invitations, viewer }; diff --git a/src/resolvers/Query/invitations.js b/src/resolvers/Query/invitations.js new file mode 100644 index 0000000..5ba348b --- /dev/null +++ b/src/resolvers/Query/invitations.js @@ -0,0 +1,24 @@ +import getPaginatedResponseFromAggregate from "@reactioncommerce/api-utils/graphql/getPaginatedResponseFromAggregate.js"; +import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; + +/** + * @name Query/invitations + * @method + * @memberof Accounts/GraphQL + * @summary query the Accounts collection and return a list of invitations + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} [args.groupIds] - Array of group IDs + * @param {Object} context - an object containing the per-request state + * @param {Object} info Info about the GraphQL request + * @returns {Promise} Promise containing queried invitations + */ +export default async function invitations(_, args, context, info) { + const { collection, pipeline } = await context.queries.invitationsAggregate(context); + + return getPaginatedResponseFromAggregate(collection, pipeline, args, { + includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), + includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), + includeTotalCount: wasFieldRequested("totalCount", info) + }); +} diff --git a/src/schemas/inviteShopMember.graphql b/src/schemas/inviteShopMember.graphql index 758a750..4656b75 100644 --- a/src/schemas/inviteShopMember.graphql +++ b/src/schemas/inviteShopMember.graphql @@ -88,12 +88,6 @@ extend type Mutation { extend type Query { "Returns all pending staff member invitations" invitations( - "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." - after: ConnectionCursor, - - "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." - before: ConnectionCursor, - "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." first: ConnectionLimitInt, From c9d43e0b1a74c284654290dc8e00e155eb56bade Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Mon, 4 May 2020 18:17:30 +0200 Subject: [PATCH 03/11] fix: update permissions Signed-off-by: Loan Laux --- src/policies.json | 9 +++++++++ src/queries/invitationsAggregate.js | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/policies.json b/src/policies.json index 0a76e82..cd722b3 100644 --- a/src/policies.json +++ b/src/policies.json @@ -37,6 +37,15 @@ ], "effect": "allow" }, + { + "description": "Account managers acting on all invitations.", + "subjects": [ "reaction:groups:account-managers" ], + "resources": [ "reaction:legacy:invitations" ], + "actions": [ + "read" + ], + "effect": "allow" + }, { "description": "Account owner acting on their account.", "subjects": [ "reaction:users:*" ], diff --git a/src/queries/invitationsAggregate.js b/src/queries/invitationsAggregate.js index d3672e7..e72dee6 100644 --- a/src/queries/invitationsAggregate.js +++ b/src/queries/invitationsAggregate.js @@ -12,7 +12,7 @@ export default async function accounts(context, input) { const { collections } = context; const { AccountInvites } = collections; - await context.validatePermissions("reaction:legacy:accounts", "read"); + await context.validatePermissions("reaction:legacy:invitations", "read"); return { collection: AccountInvites, From f6cbd59d04d11f4fba474001e407c6df99564f9e Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Thu, 7 May 2020 12:41:03 +0200 Subject: [PATCH 04/11] feat: add shopIds input field Signed-off-by: Loan Laux --- src/queries/invitationsAggregate.js | 1 + src/resolvers/Query/invitations.js | 4 ++-- src/schemas/inviteShopMember.graphql | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/queries/invitationsAggregate.js b/src/queries/invitationsAggregate.js index e72dee6..bed94b8 100644 --- a/src/queries/invitationsAggregate.js +++ b/src/queries/invitationsAggregate.js @@ -11,6 +11,7 @@ export default async function accounts(context, input) { const { collections } = context; const { AccountInvites } = collections; + const { shopIds } = input; await context.validatePermissions("reaction:legacy:invitations", "read"); diff --git a/src/resolvers/Query/invitations.js b/src/resolvers/Query/invitations.js index 5ba348b..7c9e03e 100644 --- a/src/resolvers/Query/invitations.js +++ b/src/resolvers/Query/invitations.js @@ -8,13 +8,13 @@ import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldReque * @summary query the Accounts collection and return a list of invitations * @param {Object} _ - unused * @param {Object} args - an object of all arguments that were sent by the client - * @param {String} [args.groupIds] - Array of group IDs + * @param {String} [args.shopIds] - Array of shop IDs * @param {Object} context - an object containing the per-request state * @param {Object} info Info about the GraphQL request * @returns {Promise} Promise containing queried invitations */ export default async function invitations(_, args, context, info) { - const { collection, pipeline } = await context.queries.invitationsAggregate(context); + const { collection, pipeline } = await context.queries.invitationsAggregate(context, args); return getPaginatedResponseFromAggregate(collection, pipeline, args, { includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), diff --git a/src/schemas/inviteShopMember.graphql b/src/schemas/inviteShopMember.graphql index 4656b75..88a6b8f 100644 --- a/src/schemas/inviteShopMember.graphql +++ b/src/schemas/inviteShopMember.graphql @@ -88,6 +88,9 @@ extend type Mutation { extend type Query { "Returns all pending staff member invitations" invitations( + "The shop IDs to get invitations for" + shopIds: [ID], + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." first: ConnectionLimitInt, From 1d48ec64e155f99d6c5f918ce7a47c4295c5f758 Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Thu, 7 May 2020 15:17:32 +0200 Subject: [PATCH 05/11] feat: decode and pass on shopIds Signed-off-by: Loan Laux --- src/queries/invitationsAggregate.js | 109 ++++++++++++++++------------ src/resolvers/Query/invitations.js | 13 +++- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/src/queries/invitationsAggregate.js b/src/queries/invitationsAggregate.js index bed94b8..2938b52 100644 --- a/src/queries/invitationsAggregate.js +++ b/src/queries/invitationsAggregate.js @@ -5,59 +5,76 @@ * @summary Returns accounts optionally filtered by group IDs * @param {Object} context - an object containing the per-request state * @param {String} input - input for query - * @param {String} [input.groupIds] - Array of group IDs to limit the results + * @param {String} [input.shopIds] - Array of shop IDs to limit the results * @returns {Promise} Mongo cursor */ -export default async function accounts(context, input) { +export default async function accounts(context, { shopIds }) { const { collections } = context; const { AccountInvites } = collections; - const { shopIds } = input; - await context.validatePermissions("reaction:legacy:invitations", "read"); + const pipeline = []; - return { - collection: AccountInvites, - pipeline: [ - { - $lookup: { - from: "Shops", - localField: "shopId", - foreignField: "_id", - as: "shop" - } - }, - { - $unwind: { - path: "$shop" - } - }, - { - $lookup: { - from: "Groups", - localField: "groupIds", - foreignField: "_id", - as: "groups" - } - }, - { - $lookup: { - from: "Accounts", - localField: "invitedByUserId", - foreignField: "userId", - as: "invitedBy" - } - }, - { - $unwind: { - path: "$invitedBy" - } - }, - { - $project: { - groupIds: 0, - invitedByUserId: 0 + if (Array.isArray(shopIds) && shopIds.length > 0) { + await Promise.all(shopIds.map(async (shopId) => { + await context.validatePermissions("reaction:legacy:invitations", "read", { shopId }); + })); + + pipeline.push({ + $match: { + shopId: { + $in: shopIds } } - ] + }) + } else { + await context.validatePermissions("reaction:legacy:invitations", "read"); + } + + pipeline.push( + { + $lookup: { + from: "Shops", + localField: "shopId", + foreignField: "_id", + as: "shop" + } + }, + { + $unwind: { + path: "$shop" + } + }, + { + $lookup: { + from: "Groups", + localField: "groupIds", + foreignField: "_id", + as: "groups" + } + }, + { + $lookup: { + from: "Accounts", + localField: "invitedByUserId", + foreignField: "userId", + as: "invitedBy" + } + }, + { + $unwind: { + path: "$invitedBy" + } + }, + { + $project: { + groupIds: 0, + invitedByUserId: 0 + } + } + ); + + return { + collection: AccountInvites, + pipeline }; } diff --git a/src/resolvers/Query/invitations.js b/src/resolvers/Query/invitations.js index 7c9e03e..96455d8 100644 --- a/src/resolvers/Query/invitations.js +++ b/src/resolvers/Query/invitations.js @@ -1,5 +1,6 @@ import getPaginatedResponseFromAggregate from "@reactioncommerce/api-utils/graphql/getPaginatedResponseFromAggregate.js"; import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; +import { decodeShopOpaqueId } from "../../xforms/id.js"; /** * @name Query/invitations @@ -14,9 +15,17 @@ import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldReque * @returns {Promise} Promise containing queried invitations */ export default async function invitations(_, args, context, info) { - const { collection, pipeline } = await context.queries.invitationsAggregate(context, args); + const { shopIds: encodedShopIds, ...connectionArgs } = args; - return getPaginatedResponseFromAggregate(collection, pipeline, args, { + let shopIds; + + if (Array.isArray(encodedShopIds) && encodedShopIds.length > 0) { + shopIds = encodedShopIds.map((shopId) => decodeShopOpaqueId(shopId)); + } + + const { collection, pipeline } = await context.queries.invitationsAggregate(context, { shopIds }); + + return getPaginatedResponseFromAggregate(collection, pipeline, connectionArgs, { includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), includeTotalCount: wasFieldRequested("totalCount", info) From 46baa7b0ca0ecf3044a57ab29929e94076d5e06d Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Thu, 7 May 2020 15:21:50 +0200 Subject: [PATCH 06/11] feat: add object-specific policy Signed-off-by: Loan Laux --- src/policies.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/policies.json b/src/policies.json index cd722b3..1decf6f 100644 --- a/src/policies.json +++ b/src/policies.json @@ -46,6 +46,15 @@ ], "effect": "allow" }, + { + "description": "Account managers acting on specific invitations.", + "subjects": [ "reaction:groups:account-managers" ], + "resources": [ "reaction:legacy:invitations:*" ], + "actions": [ + "read" + ], + "effect": "allow" + }, { "description": "Account owner acting on their account.", "subjects": [ "reaction:users:*" ], From 7431646f8ec1428b723a25672ed25ffe3394843e Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Thu, 7 May 2020 15:59:47 +0200 Subject: [PATCH 07/11] feat: encode invitation IDs Signed-off-by: Loan Laux --- src/resolvers/Invitation.js | 5 +++++ src/resolvers/index.js | 2 ++ src/xforms/id.js | 3 +++ 3 files changed, 10 insertions(+) create mode 100644 src/resolvers/Invitation.js diff --git a/src/resolvers/Invitation.js b/src/resolvers/Invitation.js new file mode 100644 index 0000000..13b45a0 --- /dev/null +++ b/src/resolvers/Invitation.js @@ -0,0 +1,5 @@ +import { encodeInvitationOpaqueId } from "../xforms/id.js"; + +export default { + _id: (node) => encodeInvitationOpaqueId(node._id) +} diff --git a/src/resolvers/index.js b/src/resolvers/index.js index ff4266c..effc297 100644 --- a/src/resolvers/index.js +++ b/src/resolvers/index.js @@ -2,6 +2,7 @@ import getConnectionTypeResolvers from "@reactioncommerce/api-utils/graphql/getC import Account from "./Account/index.js"; import AddAccountAddressBookEntryPayload from "./AddAccountAddressBookEntryPayload.js"; import Group from "./Group/index.js"; +import Invitation from "./Invitation.js"; import Mutation from "./Mutation/index.js"; import Query from "./Query/index.js"; import Shop from "./Shop/index.js"; @@ -15,6 +16,7 @@ export default { Account, AddAccountAddressBookEntryPayload, Group, + Invitation, Mutation, Query, Shop, diff --git a/src/xforms/id.js b/src/xforms/id.js index ffed299..6b988fb 100644 --- a/src/xforms/id.js +++ b/src/xforms/id.js @@ -10,17 +10,20 @@ const namespaces = { Account: "reaction/account", Address: "reaction/address", Group: "reaction/group", + Invitation: "reaction/invitation", Shop: "reaction/shop" }; export const encodeAccountOpaqueId = encodeOpaqueId(namespaces.Account); export const encodeAddressOpaqueId = encodeOpaqueId(namespaces.Address); export const encodeGroupOpaqueId = encodeOpaqueId(namespaces.Group); +export const encodeInvitationOpaqueId = encodeOpaqueId(namespaces.Invitation); export const encodeShopOpaqueId = encodeOpaqueId(namespaces.Shop); export const decodeAccountOpaqueId = decodeOpaqueIdForNamespace(namespaces.Account); export const decodeAddressOpaqueId = decodeOpaqueIdForNamespace(namespaces.Address); export const decodeGroupOpaqueId = decodeOpaqueIdForNamespace(namespaces.Group); +export const decodeInvitationOpaqueId = decodeOpaqueIdForNamespace(namespaces.Invitation); export const decodeShopOpaqueId = decodeOpaqueIdForNamespace(namespaces.Shop); /** From db04e65cdd7fdd3cc4234930e54194bdc375ea70 Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Sun, 24 May 2020 20:35:04 +0200 Subject: [PATCH 08/11] refactor: remove invitation-specific roles Signed-off-by: Loan Laux --- src/policies.json | 18 ------------------ src/queries/invitationsAggregate.js | 4 +--- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/policies.json b/src/policies.json index 1decf6f..0a76e82 100644 --- a/src/policies.json +++ b/src/policies.json @@ -37,24 +37,6 @@ ], "effect": "allow" }, - { - "description": "Account managers acting on all invitations.", - "subjects": [ "reaction:groups:account-managers" ], - "resources": [ "reaction:legacy:invitations" ], - "actions": [ - "read" - ], - "effect": "allow" - }, - { - "description": "Account managers acting on specific invitations.", - "subjects": [ "reaction:groups:account-managers" ], - "resources": [ "reaction:legacy:invitations:*" ], - "actions": [ - "read" - ], - "effect": "allow" - }, { "description": "Account owner acting on their account.", "subjects": [ "reaction:users:*" ], diff --git a/src/queries/invitationsAggregate.js b/src/queries/invitationsAggregate.js index 2938b52..25b2bec 100644 --- a/src/queries/invitationsAggregate.js +++ b/src/queries/invitationsAggregate.js @@ -15,9 +15,7 @@ export default async function accounts(context, { shopIds }) { const pipeline = []; if (Array.isArray(shopIds) && shopIds.length > 0) { - await Promise.all(shopIds.map(async (shopId) => { - await context.validatePermissions("reaction:legacy:invitations", "read", { shopId }); - })); + await Promise.all(shopIds.map((shopId) => context.validatePermissions("reaction:legacy:groups", "manage:accounts", { shopId }))); pipeline.push({ $match: { From af47d72b3a983fc8cd922a856282c4068150b720 Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Mon, 25 May 2020 18:37:18 +0200 Subject: [PATCH 09/11] refactor: use field resolvers to join invitation data Signed-off-by: Loan Laux --- src/queries/accountByUserId.js | 25 +++++++++++++++++++++++++ src/queries/index.js | 2 ++ src/resolvers/Invitation.js | 5 ----- src/resolvers/Invitation/groups.js | 12 ++++++++++++ src/resolvers/Invitation/index.js | 11 +++++++++++ src/resolvers/Invitation/invitedBy.js | 13 +++++++++++++ src/resolvers/index.js | 2 +- 7 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 src/queries/accountByUserId.js delete mode 100644 src/resolvers/Invitation.js create mode 100644 src/resolvers/Invitation/groups.js create mode 100644 src/resolvers/Invitation/index.js create mode 100644 src/resolvers/Invitation/invitedBy.js diff --git a/src/queries/accountByUserId.js b/src/queries/accountByUserId.js new file mode 100644 index 0000000..d3ce972 --- /dev/null +++ b/src/queries/accountByUserId.js @@ -0,0 +1,25 @@ +import ReactionError from "@reactioncommerce/reaction-error"; + +/** + * @name accountByUserId + * @method + * @memberof Accounts/NoMeteorQueries + * @summary query the Accounts collection and return user account data + * @param {Object} context - an object containing the per-request state + * @param {String} id - id of user to query + * @returns {Object} user account object + */ +export default async function accountByUserIdQuery(context, id) { + const { collections } = context; + const { Accounts } = collections; + + const account = await Accounts.findOne({ userId: id }); + if (!account) throw new ReactionError("not-found", "No account found"); + + // Check to make sure current user has permissions to view queried user + await context.validatePermissions("reaction:legacy:accounts", "read", { + owner: account.userId + }); + + return account; +} diff --git a/src/queries/index.js b/src/queries/index.js index 217ce9b..5726f5c 100644 --- a/src/queries/index.js +++ b/src/queries/index.js @@ -1,3 +1,4 @@ +import accountByUserId from "./accountByUserId.js"; import accounts from "./accounts.js"; import group from "./group.js"; import groups from "./groups.js"; @@ -6,6 +7,7 @@ import invitationsAggregate from "./invitationsAggregate.js"; import userAccount from "./userAccount.js"; export default { + accountByUserId, accounts, group, groups, diff --git a/src/resolvers/Invitation.js b/src/resolvers/Invitation.js deleted file mode 100644 index 13b45a0..0000000 --- a/src/resolvers/Invitation.js +++ /dev/null @@ -1,5 +0,0 @@ -import { encodeInvitationOpaqueId } from "../xforms/id.js"; - -export default { - _id: (node) => encodeInvitationOpaqueId(node._id) -} diff --git a/src/resolvers/Invitation/groups.js b/src/resolvers/Invitation/groups.js new file mode 100644 index 0000000..98b701f --- /dev/null +++ b/src/resolvers/Invitation/groups.js @@ -0,0 +1,12 @@ +/** + * Return group by ID + * @param parent + * @param _ + * @param context + * @returns {Promise<*[]>} + */ +export default async function groups(parent, _, context) { + const group = await context.queries.group(context, parent.groupId); + + return [group]; +} diff --git a/src/resolvers/Invitation/index.js b/src/resolvers/Invitation/index.js new file mode 100644 index 0000000..e2be202 --- /dev/null +++ b/src/resolvers/Invitation/index.js @@ -0,0 +1,11 @@ +import resolveShopFromShopId from "@reactioncommerce/api-utils/graphql/resolveShopFromShopId.js"; +import { encodeInvitationOpaqueId } from "../../xforms/id.js"; +import groups from "./groups.js"; +import invitedBy from "./invitedBy.js"; + +export default { + _id: (node) => encodeInvitationOpaqueId(node._id), + groups, + invitedBy, + shop: resolveShopFromShopId +}; diff --git a/src/resolvers/Invitation/invitedBy.js b/src/resolvers/Invitation/invitedBy.js new file mode 100644 index 0000000..beb82bb --- /dev/null +++ b/src/resolvers/Invitation/invitedBy.js @@ -0,0 +1,13 @@ +/** + * Returns the account that sent the invitation + * @param parent + * @param _ + * @param context + * @returns {Promise|null} + */ +export default function invitedBy(parent, _, context) { + const { invitedByUserId } = parent; + if (!invitedByUserId) return null; + + return context.queries.accountByUserId(context, invitedByUserId); +} diff --git a/src/resolvers/index.js b/src/resolvers/index.js index effc297..97eee01 100644 --- a/src/resolvers/index.js +++ b/src/resolvers/index.js @@ -2,7 +2,7 @@ import getConnectionTypeResolvers from "@reactioncommerce/api-utils/graphql/getC import Account from "./Account/index.js"; import AddAccountAddressBookEntryPayload from "./AddAccountAddressBookEntryPayload.js"; import Group from "./Group/index.js"; -import Invitation from "./Invitation.js"; +import Invitation from "./Invitation/index.js"; import Mutation from "./Mutation/index.js"; import Query from "./Query/index.js"; import Shop from "./Shop/index.js"; From 282e7d0a649a5855f92f1129dd5e53b477cbdd0b Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Tue, 26 May 2020 11:11:01 +0200 Subject: [PATCH 10/11] feat: enable many group IDs per invite & use query instead of aggregate Signed-off-by: Loan Laux --- src/queries/groupsById.js | 34 ++++++++++++ src/queries/index.js | 6 ++- src/queries/invitations.js | 28 ++++++++++ src/queries/invitationsAggregate.js | 78 ---------------------------- src/resolvers/Invitation/groups.js | 12 ----- src/resolvers/Invitation/index.js | 3 +- src/resolvers/Query/invitations.js | 6 +-- src/schemas/inviteShopMember.graphql | 6 +++ 8 files changed, 76 insertions(+), 97 deletions(-) create mode 100644 src/queries/groupsById.js create mode 100644 src/queries/invitations.js delete mode 100644 src/queries/invitationsAggregate.js delete mode 100644 src/resolvers/Invitation/groups.js diff --git a/src/queries/groupsById.js b/src/queries/groupsById.js new file mode 100644 index 0000000..36cb1d0 --- /dev/null +++ b/src/queries/groupsById.js @@ -0,0 +1,34 @@ +import ReactionError from "@reactioncommerce/reaction-error"; + +/** + * @name groupsById + * @method + * @memberof Accounts/NoMeteorQueries + * @summary query the Groups collection and return a MongoDB cursor + * @param {Object} context - an object containing the per-request state + * @param {Array|String} groupIds - IDs of the groups to get + * @returns {Array|Object} Group objects + */ +export default async function groupsById(context, groupIds) { + const { collections } = context; + const { Groups } = collections; + + const groups = await Groups.find({ + _id: { + $in: groupIds + } + }).toArray(); + + + if (groups.length === 0) { + throw new ReactionError("not-found", "No groups matching the provided IDs were found"); + } + + if (groups.length !== groupIds.length) { + throw new ReactionError("not-found", `Could not find ${groupIds.length - groups.length} of ${groupIds.length} groups provided`); + } + + await Promise.all(groups.map((group) => context.validatePermissions("reaction:legacy:groups", "read", { shopId: group.shopId }))); + + return groups; +} diff --git a/src/queries/index.js b/src/queries/index.js index 5726f5c..37b3d7b 100644 --- a/src/queries/index.js +++ b/src/queries/index.js @@ -3,7 +3,8 @@ import accounts from "./accounts.js"; import group from "./group.js"; import groups from "./groups.js"; import groupsByAccount from "./groupsByAccount.js"; -import invitationsAggregate from "./invitationsAggregate.js"; +import groupsById from "./groupsById.js"; +import invitations from "./invitations.js"; import userAccount from "./userAccount.js"; export default { @@ -12,6 +13,7 @@ export default { group, groups, groupsByAccount, - invitationsAggregate, + groupsById, + invitations, userAccount }; diff --git a/src/queries/invitations.js b/src/queries/invitations.js new file mode 100644 index 0000000..9fff8b3 --- /dev/null +++ b/src/queries/invitations.js @@ -0,0 +1,28 @@ +/** + * @name accounts + * @method + * @memberof Accounts/NoMeteorQueries + * @summary Returns accounts optionally filtered by group IDs + * @param {Object} context - an object containing the per-request state + * @param {String} input - input for query + * @param {String} [input.shopIds] - Array of shop IDs to limit the results + * @returns {Promise} Mongo cursor + */ +export default async function accounts(context, { shopIds }) { + const { collections } = context; + const { AccountInvites } = collections; + + if (Array.isArray(shopIds) && shopIds.length > 0) { + await Promise.all(shopIds.map((shopId) => context.validatePermissions("reaction:legacy:groups", "manage:accounts", { shopId }))); + + return AccountInvites.find({ + shopId: { + $in: shopIds + } + }); + } + + await context.validatePermissions("reaction:legacy:invitations", "read"); + + return AccountInvites.find(); +} diff --git a/src/queries/invitationsAggregate.js b/src/queries/invitationsAggregate.js deleted file mode 100644 index 25b2bec..0000000 --- a/src/queries/invitationsAggregate.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @name accounts - * @method - * @memberof Accounts/NoMeteorQueries - * @summary Returns accounts optionally filtered by group IDs - * @param {Object} context - an object containing the per-request state - * @param {String} input - input for query - * @param {String} [input.shopIds] - Array of shop IDs to limit the results - * @returns {Promise} Mongo cursor - */ -export default async function accounts(context, { shopIds }) { - const { collections } = context; - const { AccountInvites } = collections; - - const pipeline = []; - - if (Array.isArray(shopIds) && shopIds.length > 0) { - await Promise.all(shopIds.map((shopId) => context.validatePermissions("reaction:legacy:groups", "manage:accounts", { shopId }))); - - pipeline.push({ - $match: { - shopId: { - $in: shopIds - } - } - }) - } else { - await context.validatePermissions("reaction:legacy:invitations", "read"); - } - - pipeline.push( - { - $lookup: { - from: "Shops", - localField: "shopId", - foreignField: "_id", - as: "shop" - } - }, - { - $unwind: { - path: "$shop" - } - }, - { - $lookup: { - from: "Groups", - localField: "groupIds", - foreignField: "_id", - as: "groups" - } - }, - { - $lookup: { - from: "Accounts", - localField: "invitedByUserId", - foreignField: "userId", - as: "invitedBy" - } - }, - { - $unwind: { - path: "$invitedBy" - } - }, - { - $project: { - groupIds: 0, - invitedByUserId: 0 - } - } - ); - - return { - collection: AccountInvites, - pipeline - }; -} diff --git a/src/resolvers/Invitation/groups.js b/src/resolvers/Invitation/groups.js deleted file mode 100644 index 98b701f..0000000 --- a/src/resolvers/Invitation/groups.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Return group by ID - * @param parent - * @param _ - * @param context - * @returns {Promise<*[]>} - */ -export default async function groups(parent, _, context) { - const group = await context.queries.group(context, parent.groupId); - - return [group]; -} diff --git a/src/resolvers/Invitation/index.js b/src/resolvers/Invitation/index.js index e2be202..f3a5cee 100644 --- a/src/resolvers/Invitation/index.js +++ b/src/resolvers/Invitation/index.js @@ -1,11 +1,10 @@ import resolveShopFromShopId from "@reactioncommerce/api-utils/graphql/resolveShopFromShopId.js"; import { encodeInvitationOpaqueId } from "../../xforms/id.js"; -import groups from "./groups.js"; import invitedBy from "./invitedBy.js"; export default { _id: (node) => encodeInvitationOpaqueId(node._id), - groups, + groups: (parent, _, context) => context.queries.groupsById(context, parent.groupIds || [parent.groupId]), invitedBy, shop: resolveShopFromShopId }; diff --git a/src/resolvers/Query/invitations.js b/src/resolvers/Query/invitations.js index 96455d8..e47d9f8 100644 --- a/src/resolvers/Query/invitations.js +++ b/src/resolvers/Query/invitations.js @@ -1,4 +1,4 @@ -import getPaginatedResponseFromAggregate from "@reactioncommerce/api-utils/graphql/getPaginatedResponseFromAggregate.js"; +import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js"; import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; import { decodeShopOpaqueId } from "../../xforms/id.js"; @@ -23,9 +23,9 @@ export default async function invitations(_, args, context, info) { shopIds = encodedShopIds.map((shopId) => decodeShopOpaqueId(shopId)); } - const { collection, pipeline } = await context.queries.invitationsAggregate(context, { shopIds }); + const query = await context.queries.invitations(context, { shopIds }); - return getPaginatedResponseFromAggregate(collection, pipeline, connectionArgs, { + return getPaginatedResponse(query, connectionArgs, { includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), includeTotalCount: wasFieldRequested("totalCount", info) diff --git a/src/schemas/inviteShopMember.graphql b/src/schemas/inviteShopMember.graphql index 88a6b8f..c974b42 100644 --- a/src/schemas/inviteShopMember.graphql +++ b/src/schemas/inviteShopMember.graphql @@ -91,6 +91,12 @@ extend type Query { "The shop IDs to get invitations for" shopIds: [ID], + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor, + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor, + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." first: ConnectionLimitInt, From 6ef13b5c9bfa66e15e92914f197673e9056a9fba Mon Sep 17 00:00:00 2001 From: Loan Laux Date: Tue, 26 May 2020 11:13:25 +0200 Subject: [PATCH 11/11] chore: update JSDoc Signed-off-by: Loan Laux --- src/queries/invitations.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/queries/invitations.js b/src/queries/invitations.js index 9fff8b3..9c03f7b 100644 --- a/src/queries/invitations.js +++ b/src/queries/invitations.js @@ -1,14 +1,14 @@ /** - * @name accounts + * @name invitations * @method * @memberof Accounts/NoMeteorQueries - * @summary Returns accounts optionally filtered by group IDs + * @summary Returns invitations optionally filtered by shop IDs * @param {Object} context - an object containing the per-request state * @param {String} input - input for query * @param {String} [input.shopIds] - Array of shop IDs to limit the results * @returns {Promise} Mongo cursor */ -export default async function accounts(context, { shopIds }) { +export default async function invitations(context, { shopIds }) { const { collections } = context; const { AccountInvites } = collections;