Skip to content
This repository has been archived by the owner on Sep 21, 2022. It is now read-only.

Add invitations query #16

Merged
merged 11 commits into from
Jun 2, 2020
25 changes: 25 additions & 0 deletions src/queries/accountByUserId.js
Original file line number Diff line number Diff line change
@@ -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;
}
34 changes: 34 additions & 0 deletions src/queries/groupsById.js
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions src/queries/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import accountByUserId from "./accountByUserId.js";
import accounts from "./accounts.js";
import group from "./group.js";
import groups from "./groups.js";
import groupsByAccount from "./groupsByAccount.js";
import groupsById from "./groupsById.js";
import invitations from "./invitations.js";
import userAccount from "./userAccount.js";

export default {
accountByUserId,
accounts,
group,
groups,
groupsByAccount,
groupsById,
invitations,
userAccount
};
28 changes: 28 additions & 0 deletions src/queries/invitations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @name invitations
* @method
* @memberof Accounts/NoMeteorQueries
* @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 invitations(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();
}
10 changes: 10 additions & 0 deletions src/resolvers/Invitation/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import resolveShopFromShopId from "@reactioncommerce/api-utils/graphql/resolveShopFromShopId.js";
import { encodeInvitationOpaqueId } from "../../xforms/id.js";
import invitedBy from "./invitedBy.js";

export default {
_id: (node) => encodeInvitationOpaqueId(node._id),
groups: (parent, _, context) => context.queries.groupsById(context, parent.groupIds || [parent.groupId]),
invitedBy,
shop: resolveShopFromShopId
};
13 changes: 13 additions & 0 deletions src/resolvers/Invitation/invitedBy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Returns the account that sent the invitation
* @param parent
* @param _
* @param context
* @returns {Promise<Object|null>|null}
*/
export default function invitedBy(parent, _, context) {
const { invitedByUserId } = parent;
if (!invitedByUserId) return null;

return context.queries.accountByUserId(context, invitedByUserId);
}
2 changes: 2 additions & 0 deletions src/resolvers/Query/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ 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 {
account,
accounts,
group,
groups,
invitations,
viewer
};
33 changes: 33 additions & 0 deletions src/resolvers/Query/invitations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js";
import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js";
import { decodeShopOpaqueId } from "../../xforms/id.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.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<Object>} Promise containing queried invitations
*/
export default async function invitations(_, args, context, info) {
const { shopIds: encodedShopIds, ...connectionArgs } = args;

let shopIds;

if (Array.isArray(encodedShopIds) && encodedShopIds.length > 0) {
shopIds = encodedShopIds.map((shopId) => decodeShopOpaqueId(shopId));
}

const query = await context.queries.invitations(context, { shopIds });

return getPaginatedResponse(query, connectionArgs, {
includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info),
includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info),
includeTotalCount: wasFieldRequested("totalCount", info)
});
}
2 changes: 2 additions & 0 deletions src/resolvers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/index.js";
import Mutation from "./Mutation/index.js";
import Query from "./Query/index.js";
import Shop from "./Shop/index.js";
Expand All @@ -15,6 +16,7 @@ export default {
Account,
AddAccountAddressBookEntryPayload,
Group,
Invitation,
Mutation,
Query,
Shop,
Expand Down
78 changes: 78 additions & 0 deletions src/schemas/inviteShopMember.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,3 +84,32 @@ extend type Mutation {
input: InviteShopMemberInput!
): InviteShopMemberPayload
}

extend type Query {
"Returns all pending staff member invitations"
invitations(
"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,

"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!
}
3 changes: 3 additions & 0 deletions src/xforms/id.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand Down