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

Add Contributor(s) Query #10624

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
85 changes: 85 additions & 0 deletions server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -4244,6 +4244,11 @@
collectiveSlug: String @deprecated(reason: "2024-08-26: Use account.slug instead")
account: Account

"""
List of accounts the contributor has contributed to
"""
accountsContributedTo: [Account]

Check notice on line 4250 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'accountsContributedTo' was added to object type 'Contributor'

Field 'accountsContributedTo' was added to object type 'Contributor'

"""
Contributor avatar or logo
"""
Expand Down Expand Up @@ -4715,6 +4720,11 @@
"""
HOST_ADMIN

"""
Not a Fiscal Host Admin
"""
NON_HOST_ADMIN

Check warning on line 4726 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Enum value 'NON_HOST_ADMIN' was added to enum 'LastCommentBy'

Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.

"""
Collective Admin
"""
Expand Down Expand Up @@ -14607,6 +14617,51 @@
id: String!
): Conversation

"""
Get Contributors grouped by their profiles
"""
contributors(

Check notice on line 14623 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'contributors' was added to object type 'Query'

Field 'contributors' was added to object type 'Query'
"""
Host hosting the account
"""
account: AccountReferenceInput

"""
Host hosting the account
"""
host: AccountReferenceInput
limit: Int! = 100
offset: Int! = 0
role: [MemberRole]
type: [AccountType]

"""
Admin only. To filter on the email address of a member, useful to check if a member exists.
"""
email: EmailAddress

"""
Order of the results
"""
orderBy: ChronologicalOrderInput! = { field: CREATED_AT, direction: ASC }
includeInherited: Boolean = true
): ContributorCollection!

"""
Get Contributors grouped by their profiles
"""
contributor(

Check notice on line 14653 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'contributor' was added to object type 'Query'

Field 'contributor' was added to object type 'Query'
"""
Contributor Account reference
"""
account: AccountReferenceInput

"""
Context host to fetch the contributor from
"""
host: AccountReferenceInput
): Contributor!

"""
Get exchange rates from Open Collective
"""
Expand Down Expand Up @@ -14928,6 +14983,36 @@
"""
role: [MemberRole]
): [MemberInvitation]

"""
Get all members (admins, members, backers, followers)
"""
members(

Check notice on line 14990 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'members' was added to object type 'Query'

Field 'members' was added to object type 'Query'
"""
Host hosting the account
"""
account: AccountReferenceInput

"""
Host hosting the account
"""
host: AccountReferenceInput
limit: Int! = 100
offset: Int! = 0
role: [MemberRole]
accountType: [AccountType]

"""
Admin only. To filter on the email address of a member, useful to check if a member exists.
"""
email: EmailAddress

"""
Order of the results
"""
orderBy: ChronologicalOrderInput! = { field: CREATED_AT, direction: ASC }
includeInherited: Boolean = true
): MemberCollection!
order(
"""
Identifiers to retrieve the Order
Expand Down
85 changes: 4 additions & 81 deletions server/graphql/v2/interface/HasMembers.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { GraphQLBoolean, GraphQLInt, GraphQLList, GraphQLNonNull } from 'graphql';
import { intersection, isNil } from 'lodash';

import { CollectiveType } from '../../../constants/collectives';
import MemberRoles from '../../../constants/roles';
import models, { Op, sequelize } from '../../../models';
import { checkScope } from '../../common/scope-check';
import { BadRequest } from '../../errors';
import { GraphQLMemberCollection } from '../collection/MemberCollection';
import { AccountTypeToModelMapping, GraphQLAccountType } from '../enum/AccountType';
import { GraphQLAccountType } from '../enum/AccountType';
import { GraphQLMemberRole } from '../enum/MemberRole';
import { GraphQLChronologicalOrderInput } from '../input/ChronologicalOrderInput';
import MembersCollectionQuery from '../query/collection/MembersCollectionQuery';
import MemberInvitationsQuery from '../query/MemberInvitationsQuery';
import EmailAddress from '../scalar/EmailAddress';

Expand All @@ -36,80 +31,8 @@ export const HasMembersFields = {
defaultValue: true,
},
},
async resolve(collective, args, req) {
// Check Pagination arguments
if (isNil(args.limit) || args.limit < 0) {
args.limit = 100;
}
if (isNil(args.offset) || args.offset < 0) {
args.offset = 0;
}
if (args.limit > 1000 && !req.remoteUser?.isRoot()) {
throw new Error('Cannot fetch more than 1,000 members at the same time, please adjust the limit');
}

// TODO: isn't it a better practice to return null?
if (collective.isIncognito && (!req.remoteUser?.isAdmin(collective.id) || !checkScope(req, 'incognito'))) {
return { offset: args.offset, limit: args.limit, totalCount: 0, nodes: [] };
}

let where = { CollectiveId: collective.id };
const collectiveInclude = [];

if (args.role && args.role.length > 0) {
where.role = { [Op.in]: args.role };
}
const collectiveConditions = { deletedAt: null };
if (args.accountType && args.accountType.length > 0) {
collectiveConditions.type = {
[Op.in]: args.accountType.map(value => AccountTypeToModelMapping[value]),
};
}

// Inherit Accountants and Admin from parent collective for Events and Projects
if (args.includeInherited && [CollectiveType.EVENT, CollectiveType.PROJECT].includes(collective.type)) {
const inheritedRoles = [MemberRoles.ACCOUNTANT, MemberRoles.ADMIN, MemberRoles.MEMBER];
where = {
[Op.or]: [
where,
{
CollectiveId: collective.ParentCollectiveId,
role: { [Op.in]: args.role ? intersection(args.role, inheritedRoles) : inheritedRoles },
},
],
};
}

if (args.email) {
if (!req.remoteUser?.isAdminOfCollective(collective)) {
throw new BadRequest('Only admins can lookup for members using the "email" argument');
} else {
collectiveInclude.push({ association: 'user', required: true, where: { email: args.email.toLowerCase() } });
}
}

const result = await models.Member.findAndCountAll({
where,
limit: args.limit,
offset: args.offset,
order: [[args.orderBy.field, args.orderBy.direction]],
attributes: {
include: [
[sequelize.literal(`"Member"."CollectiveId" = ${collective.ParentCollectiveId || 0}`), 'inherited'],
],
},
include: [
{
model: models.Collective,
as: 'memberCollective',
where: collectiveConditions,
include: collectiveInclude,
required: true,
},
],
});

return { nodes: result.rows, totalCount: result.count, limit: args.limit, offset: args.offset };
resolve(account, args, req) {
return MembersCollectionQuery.resolve(null, { ...args, account }, req);
},
},
memberInvitations: MemberInvitationsQuery,
Expand Down
9 changes: 9 additions & 0 deletions server/graphql/v2/object/Contributor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ export const GraphQLContributor = new GraphQLObjectType({
return req.loaders.Collective.byId.load(contributor.id);
},
},
accountsContributedTo: {
type: new GraphQLList(GraphQLAccount),
description: 'List of accounts the contributor has contributed to',
resolve(contributor, _, req): Promise<Collective[]> {
return contributor.ContributedCollectiveIds
? req.loaders.Collective.byId.loadMany(contributor.ContributedCollectiveIds)
: [];
},
},
image: {
type: GraphQLString,
description: 'Contributor avatar or logo',
Expand Down
34 changes: 34 additions & 0 deletions server/graphql/v2/query/ContributorQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { GraphQLNonNull } from 'graphql';

import { getHostContributorDetail } from '../../../lib/contributors';
import { BadRequest } from '../../errors';
import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../input/AccountReferenceInput';
import { GraphQLContributor } from '../object/Contributor';

const ContributorQuery = {
description: 'Get Contributors grouped by their profiles',
type: GraphQLContributor,
args: {
account: {
type: new GraphQLNonNull(GraphQLAccountReferenceInput),
description: 'Contributor Account reference',
},
host: {
type: new GraphQLNonNull(GraphQLAccountReferenceInput),
description: 'Context host to fetch the contributor from',
},
},
async resolve(_: void, args, req: Express.Request) {
const [account, host] = await Promise.all([
fetchAccountWithReference(args.account, { throwIfMissing: true }),
fetchAccountWithReference(args.host, { throwIfMissing: true }),
]);
if (!req.remoteUser?.isAdminOfCollective(host)) {
throw new BadRequest('Only admins can lookup for members using the "host" argument');
}

return getHostContributorDetail(account.id, host.id);
},
};

export default ContributorQuery;
103 changes: 103 additions & 0 deletions server/graphql/v2/query/collection/ContributorsCollectionQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'assert';

import { GraphQLInt, GraphQLList, GraphQLNonNull } from 'graphql';
import { isNil } from 'lodash';

import { countHostContributors, getHostContributors } from '../../../../lib/contributors';
import { BadRequest } from '../../../errors';
import { GraphQLContributorCollection } from '../../collection/ContributorCollection';
import { AccountTypeToModelMapping, GraphQLAccountType } from '../../enum/AccountType';
import { GraphQLMemberRole } from '../../enum/MemberRole';
import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../../input/AccountReferenceInput';
import EmailAddress from '../../scalar/EmailAddress';

const DEFAULT_LIMIT = 100;

const ContributorsCollectionQuery = {
description: 'Get Contributors grouped by their profiles',
type: new GraphQLNonNull(GraphQLContributorCollection),
args: {
account: {
type: GraphQLAccountReferenceInput,
description: 'Host hosting the account',
},
host: {
type: GraphQLAccountReferenceInput,
description: 'Host hosting the account',
},
role: { type: new GraphQLList(GraphQLMemberRole) },
type: { type: new GraphQLList(GraphQLAccountType) },
email: {
type: EmailAddress,
description: 'Admin only. To filter on the email address of a member, useful to check if a member exists.',
},
limit: { type: new GraphQLNonNull(GraphQLInt), defaultValue: DEFAULT_LIMIT },
offset: { type: new GraphQLNonNull(GraphQLInt), defaultValue: 0 },
},
async resolve(_: void, args, req: Express.Request) {
if (isNil(args.limit) || args.limit < 0) {
args.limit = DEFAULT_LIMIT;
}
if (isNil(args.offset) || args.offset < 0) {
args.offset = 0;
}
if (args.limit > DEFAULT_LIMIT && !req.remoteUser?.isRoot()) {
throw new Error(`Cannot fetch more than ${DEFAULT_LIMIT},members at the same time, please adjust the limit`);
}

assert(
Boolean(args.account) !== Boolean(args.host),
'You must provide either an account or a host to fetch the contributors',
);

const replacements: {
limit: number;
offset: number;
type?: string[];
role?: string[];
collectiveid?: number;
hostid?: number;
email?: string;
} = {
limit: args.limit,
offset: args.offset,
};

if (args.type && args.type.length > 0) {
replacements['type'] = args.type.map(value => AccountTypeToModelMapping[value]);
}

if (args.role && args.role.length > 0) {
replacements['role'] = args.role;
}

let account, host;
if (args.account) {
account = await fetchAccountWithReference(args.account, { throwIfMissing: true });
replacements['collectiveid'] = account.id;
}

if (args.host) {
host = await fetchAccountWithReference(args.host, { throwIfMissing: true });
if (!req.remoteUser?.isAdminOfCollective(host)) {
throw new BadRequest('Only admins can lookup for members using the "host" argument');
}
replacements['hostid'] = host.id;
}

if (args.email) {
if (req.remoteUser?.isAdminOfCollective(account) || req.remoteUser?.isAdminOfCollective(host)) {
replacements['email'] = args.email.toLowerCase();
} else {
throw new BadRequest('Only admins can lookup for members using the "email" argument');
}
}

const nodes = () => getHostContributors(replacements);
const totalCount = () => countHostContributors(replacements);

return { nodes, totalCount, limit: args.limit, offset: args.offset };
},
};

export default ContributorsCollectionQuery;
Loading
Loading