diff --git a/src/mutations/createAccount.js b/src/mutations/createAccount.js index da6deee..b3c1322 100644 --- a/src/mutations/createAccount.js +++ b/src/mutations/createAccount.js @@ -78,7 +78,7 @@ export default async function createAccount(context, input) { userId }; - let groups = []; + let groups = new Set(); let invites; // if this is the first user created overall, add them to the @@ -87,27 +87,36 @@ export default async function createAccount(context, input) { if (!anyAccount) { const accountsManagerGroupId = await ensureAccountsManagerGroup(context); const systemManagerGroupId = await ensureSystemManagerGroup(context); - groups.push(systemManagerGroupId); - groups.push(accountsManagerGroupId); + groups.add(systemManagerGroupId); + groups.add(accountsManagerGroupId); } else { // if this isn't the first account see if they were invited by another user // find all invites for this email address, for all shops, and add to all groups const emailAddresses = emails.map((emailRecord) => emailRecord.address.toLowerCase()); invites = await AccountInvites.find({ email: { $in: emailAddresses } }).toArray(); - groups = invites.map((invite) => invite.groupId); + groups = invites.reduce((allGroupIds, invite) => { + if (invite.groupIds) { + invite.groupIds.forEach((groupId) => { + return allGroupIds.add(groupId); + }); + } + + if (invite.groupId) { + allGroupIds.add(invite.groupId); + } + + return allGroupIds; + }, new Set()); } AccountSchema.validate(account); await Accounts.insertOne(account); - for (const groupId of groups) { - // eslint-disable-next-line no-await-in-loop - await context.mutations.addAccountToGroup(context.getInternalContext(), { - accountId: account._id, - groupId - }); - } + await context.mutations.updateGroupsForAccounts(context.getInternalContext(), { + accountIds: [account._id], + groupIds: Array.from(groups) + }); // Delete any invites that are now finished if (invites) { diff --git a/src/mutations/inviteShopMember.js b/src/mutations/inviteShopMember.js index 078af44..ecc94f5 100644 --- a/src/mutations/inviteShopMember.js +++ b/src/mutations/inviteShopMember.js @@ -9,7 +9,8 @@ const { REACTION_ADMIN_PUBLIC_ACCOUNT_REGISTRATION_URL } = config; const inputSchema = new SimpleSchema({ email: String, - groupId: String, + groupIds: Array, + "groupIds.$": String, name: String, shopId: String }); @@ -22,7 +23,7 @@ const inputSchema = new SimpleSchema({ * @param {Object} context - GraphQL execution context * @param {Object} input - Necessary input for mutation. See SimpleSchema. * @param {String} input.shopId - shop to invite user - * @param {String} input.groupId - groupId to invite user + * @param {String} input.groupIds - groupIds to invite user * @param {String} input.email - email of invitee * @param {String} input.name - name of invitee * @return {Promise} with boolean of found new account === true || false @@ -33,7 +34,7 @@ export default async function inviteShopMember(context, input) { const { Accounts, AccountInvites, Groups, Shops } = collections; const { email, - groupId, + groupIds, name, shopId } = input; @@ -45,8 +46,19 @@ export default async function inviteShopMember(context, input) { const shop = await Shops.findOne({ _id: shopId }); if (!shop) throw new ReactionError("not-found", "No shop found"); - const group = await Groups.findOne({ _id: groupId }); - if (!group) throw new ReactionError("not-found", "No group found"); + 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`) + } const lowercaseEmail = email.toLowerCase(); @@ -55,17 +67,25 @@ export default async function inviteShopMember(context, input) { if (invitedAccount) { // Set the account's permission group for this shop - await context.mutations.addAccountToGroup(context, { - accountId: invitedAccount._id, - groupId + await context.mutations.updateGroupsForAccounts(context, { + accountIds: [invitedAccount._id], + groupIds }); return Accounts.findOne({ _id: invitedAccount._id }); } - // This check is part of `addAccountToGroup` mutation for existing users. For new users, + const groupShopIds = groups.reduce((allShopIds, group) => { + if (!allShopIds.includes(group.shopId)) { + allShopIds.push(group.shopId); + } + + return allShopIds; + }, []); + + // This check is part of `updateGroupsForAccounts` mutation for existing users. For new users, // we do it here before creating an invite record and sending the invite email. - await context.validatePermissions("reaction:legacy:groups", "manage:accounts", { shopId: group.shopId }); + await Promise.all(groupShopIds.map((groupShopId) => context.validatePermissions("reaction:legacy:groups", "manage:accounts", { shopId: groupShopId }))); // Create an AccountInvites document. If a person eventually creates an account with this email address, // it will be automatically added to this group instead of the default group for this shop. @@ -74,7 +94,7 @@ export default async function inviteShopMember(context, input) { shopId }, { $set: { - groupId, + groupIds, invitedByUserId: userFromContext._id }, $setOnInsert: { @@ -84,11 +104,32 @@ export default async function inviteShopMember(context, input) { upsert: true }); + let formattedGroupNames = groups[0].name; + + // Generate a human-readable list of group names. + // For example, if we have groups "test1" and "test2", `formattedGroupNames` will be "test1 and test2". + // If we have groups "test1", "test2" and "test3", `formattedGroupNames` will be "test1, test2 and test3". + if (groups.length > 1) { + formattedGroupNames = groups.reduce((sentence, group, index) => { + if (index === groups.length - 1) { + return `${sentence} and ${group.name}`; + } + + if (index === 0) { + return group.name; + } + + return `${sentence}, ${group.name}`; + }, ""); + } + // Now send them an invitation email const dataForEmail = { contactEmail: _.get(shop, "emails[0].address"), copyrightDate: new Date().getFullYear(), - groupName: _.startCase(group.name), + groupName: _.startCase(groups[0].name), + groupNames: groups.map((group) => group.name), + hasMultipleGroups: groups.length > 1, legalName: _.get(shop, "addressBook[0].company"), physicalAddress: { address: `${_.get(shop, "addressBook[0].address1")} ${_.get(shop, "addressBook[0].address2")}`, diff --git a/src/resolvers/Mutation/inviteShopMember.js b/src/resolvers/Mutation/inviteShopMember.js index b7f9c33..7750a08 100644 --- a/src/resolvers/Mutation/inviteShopMember.js +++ b/src/resolvers/Mutation/inviteShopMember.js @@ -1,3 +1,4 @@ +import ReactionError from "@reactioncommerce/reaction-error"; import { decodeGroupOpaqueId, decodeShopOpaqueId } from "../../xforms/id.js"; /** @@ -8,7 +9,8 @@ import { decodeGroupOpaqueId, decodeShopOpaqueId } from "../../xforms/id.js"; * @param {Object} _ - unused * @param {Object} args.input - an object of all mutation arguments that were sent by the client * @param {String} args.input.email - The email address of the person to invite - * @param {String} args.input.groupId - The permission group for this person's new account + * @param {String} args.input.groupId - The permission group for this person's new account (deprecated) + * @param {String} args.input.groupIds - The permission groups for this person's new account * @param {String} args.input.name - The permission group for this person's new account * @param {String} args.input.shopId - The ID of the shop to which you want to invite this person * @param {String} [args.input.clientMutationId] - An optional string identifying the mutation call @@ -17,12 +19,24 @@ import { decodeGroupOpaqueId, decodeShopOpaqueId } from "../../xforms/id.js"; */ export default async function inviteShopMember(_, { input }, context) { const { email, groupId, name, shopId, clientMutationId = null } = input; - const decodedGroupId = decodeGroupOpaqueId(groupId); + let { groupIds } = input; + + // If user is passing both `groupId` and `groupIds`, throw an error + if (groupId && Array.isArray(groupIds) && groupIds.length > 0) { + throw new ReactionError("invalid-parameter", "Can't specify both groupId and groupIds."); + } + + // If user is using deprecated `groupId` instead of `groupIds`, populate `groupIds` + if (groupId && (!Array.isArray(groupIds) || groupIds.length === 0)) { + groupIds = [groupId]; + } + + const decodedGroupIds = groupIds.map((groupId) => decodeGroupOpaqueId(groupId)); const decodedShopId = decodeShopOpaqueId(shopId); const account = await context.mutations.inviteShopMember(context, { email, - groupId: decodedGroupId, + groupIds: decodedGroupIds, name, shopId: decodedShopId }); diff --git a/src/schemas/inviteShopMember.graphql b/src/schemas/inviteShopMember.graphql index 93a17ef..366dcd5 100644 --- a/src/schemas/inviteShopMember.graphql +++ b/src/schemas/inviteShopMember.graphql @@ -6,8 +6,12 @@ input InviteShopMemberInput { "The email address of the person to invite" email: String! - "The permission group for this person's new account" - groupId: ID! + "The permission group for this person's new account. DEPRECATED. Use `groupIds` field instead." + # @deprecated isn't allowed on input fields yet. See See https://github.com/graphql/graphql-spec/pull/525 + groupId: ID + + "The permission groups for this person's new account" + groupIds: [ID] "The invitee's full name" name: String!