diff --git a/README.md b/README.md index 0a0558aa..98818de2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Topcoder Challenge API This microservice provides access and interaction with all sorts of Challenge data. +## Devlopment status +[![Total alerts](https://img.shields.io/lgtm/alerts/g/topcoder-platform/challenge-api.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/topcoder-platform/challenge-api/alerts/)[![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/topcoder-platform/challenge-api.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/topcoder-platform/challenge-api/context:javascript) ### Deployment status Dev: [![CircleCI](https://circleci.com/gh/topcoder-platform/challenge-api/tree/develop.svg?style=svg)](https://circleci.com/gh/topcoder-platform/challenge-api/tree/develop) Prod: [![CircleCI](https://circleci.com/gh/topcoder-platform/challenge-api/tree/master.svg?style=svg)](https://circleci.com/gh/topcoder-platform/challenge-api/tree/master) diff --git a/app-constants.js b/app-constants.js index 3a48084d..f2ad10a3 100644 --- a/app-constants.js +++ b/app-constants.js @@ -2,8 +2,9 @@ * App constants */ const UserRoles = { - Admin: 'Administrator', - Copilot: 'Copilot', + Admin: 'administrator', + Copilot: 'copilot', + Manager: 'Connect Manager', User: 'Topcoder User' } @@ -70,6 +71,11 @@ const challengeTracks = { QA: 'QA' } +const challengeTextSortField = { + Name: 'name', + TypeId: 'typeId' +} + module.exports = { UserRoles, prizeSetTypes, @@ -78,5 +84,6 @@ module.exports = { EVENT_ORIGINATOR, EVENT_MIME_TYPE, Topics, - challengeTracks + challengeTracks, + challengeTextSortField } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 76b2d4db..5abf2b5d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -174,6 +174,28 @@ paths: type: array items: type: string + - name: includeAllTags + in: query + description: >- + Require all provided tags to be present on a challenge for a match + required: false + default: true + type: boolean + - name: events + in: query + description: >- + Filter by multiple event keys (ie: tco21) + required: false + type: array + items: + type: number + - name: includeAllEvents + in: query + description: >- + Require all provided events to be present on a challenge for a match + required: false + default: true + type: boolean - name: projectId in: query description: 'Filter by v5 project id, exact match.' diff --git a/src/routes.js b/src/routes.js index fd04d997..f7ed7d27 100644 --- a/src/routes.js +++ b/src/routes.js @@ -16,14 +16,14 @@ module.exports = { get: { controller: 'ChallengeController', method: 'searchChallenges', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.User], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager, constants.UserRoles.User], scopes: [READ, ALL] }, post: { controller: 'ChallengeController', method: 'createChallenge', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [CREATE, ALL] } }, @@ -43,14 +43,14 @@ module.exports = { controller: 'ChallengeController', method: 'fullyUpdateChallenge', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [UPDATE, ALL] }, patch: { controller: 'ChallengeController', method: 'partiallyUpdateChallenge', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [UPDATE, ALL] } }, @@ -63,7 +63,7 @@ module.exports = { controller: 'ChallengeTypeController', method: 'createChallengeType', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [CREATE, ALL] } }, @@ -76,14 +76,14 @@ module.exports = { controller: 'ChallengeTypeController', method: 'fullyUpdateChallengeType', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [UPDATE, ALL] }, patch: { controller: 'ChallengeTypeController', method: 'partiallyUpdateChallengeType', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [UPDATE, ALL] } }, @@ -96,7 +96,7 @@ module.exports = { controller: 'ChallengeTrackController', method: 'createChallengeTrack', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [CREATE, ALL] } }, @@ -109,14 +109,14 @@ module.exports = { controller: 'ChallengeTrackController', method: 'fullyUpdateChallengeTrack', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [UPDATE, ALL] }, patch: { controller: 'ChallengeTrackController', method: 'partiallyUpdateChallengeTrack', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [UPDATE, ALL] } }, @@ -132,7 +132,7 @@ module.exports = { controller: 'ChallengeTimelineTemplateController', method: 'createChallengeTimelineTemplate', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [CREATE, ALL] } }, @@ -148,14 +148,14 @@ module.exports = { controller: 'ChallengeTimelineTemplateController', method: 'fullyUpdateChallengeTimelineTemplate', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [UPDATE, ALL] }, delete: { controller: 'ChallengeTimelineTemplateController', method: 'deleteChallengeTimelineTemplate', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [DELETE, ALL] } }, @@ -187,7 +187,7 @@ module.exports = { controller: 'ChallengePhaseController', method: 'getPhase', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [READ, ALL] }, put: { @@ -231,7 +231,7 @@ module.exports = { controller: 'TimelineTemplateController', method: 'getTimelineTemplate', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [READ, ALL] }, put: { @@ -261,7 +261,7 @@ module.exports = { controller: 'AttachmentController', method: 'uploadAttachment', auth: 'jwt', - access: [constants.UserRoles.Admin, constants.UserRoles.Copilot], + access: [constants.UserRoles.Admin, constants.UserRoles.Copilot, constants.UserRoles.Manager], scopes: [CREATE, ALL] } }, diff --git a/src/scripts/seed/ChallengeType.json b/src/scripts/seed/ChallengeType.json index 6f2a07fc..3656f645 100644 --- a/src/scripts/seed/ChallengeType.json +++ b/src/scripts/seed/ChallengeType.json @@ -21,6 +21,6 @@ "description": "A piece of work assigned to one person", "isActive": true, "isTask": true, - "abbreviation": "T" + "abbreviation": "TSK" } ] diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index c0f1d93b..88bb4c26 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -154,7 +154,7 @@ async function searchChallenges (currentUser, criteria) { _.forIn(_.omit(criteria, ['types', 'tracks', 'typeIds', 'trackIds', 'type', 'name', 'trackId', 'typeId', 'description', 'page', 'perPage', 'tag', 'group', 'groups', 'memberId', 'ids', 'createdDateStart', 'createdDateEnd', 'updatedDateStart', 'updatedDateEnd', 'startDateStart', 'startDateEnd', 'endDateStart', 'endDateEnd', 'tags', 'registrationStartDateStart', 'registrationStartDateEnd', 'currentPhaseName', 'submissionStartDateStart', 'submissionStartDateEnd', - 'registrationEndDateStart', 'registrationEndDateEnd', 'submissionEndDateStart', 'submissionEndDateEnd', + 'registrationEndDateStart', 'registrationEndDateEnd', 'submissionEndDateStart', 'submissionEndDateEnd', 'includeAllEvents', 'events', 'forumId', 'track', 'reviewType', 'confidentialityType', 'directProjectId', 'sortBy', 'sortOrder', 'isLightweight', 'isTask', 'taskIsAssigned', 'taskMemberId']), (value, key) => { if (!_.isUndefined(value)) { const filter = { match_phrase: {} } @@ -251,7 +251,10 @@ async function searchChallenges (currentUser, criteria) { if (criteria.endDateEnd) { boolQuery.push({ range: { endDate: { lte: criteria.endDateEnd } } }) } - const sortByProp = criteria.sortBy ? criteria.sortBy : 'created' + + let sortByProp = criteria.sortBy ? criteria.sortBy : 'created' + // If property to sort is text, then use its sub-field 'keyword' for sorting + sortByProp = _.includes(constants.challengeTextSortField, sortByProp) ? sortByProp + '.keyword' : sortByProp const sortOrderProp = criteria.sortOrder ? criteria.sortOrder : 'desc' const mustQuery = [] @@ -271,6 +274,18 @@ async function searchChallenges (currentUser, criteria) { } } + if (criteria.events) { + if (criteria.includeAllEvents) { + for (const e of criteria.events) { + boolQuery.push({ match_phrase: { 'events.key': e } }) + } + } else { + for (const e of criteria.events) { + shouldQuery.push({ match: { 'events.key': e } }) + } + } + } + const mustNotQuery = [] let groupsToFilter = [] @@ -339,6 +354,29 @@ async function searchChallenges (currentUser, criteria) { } } + const accessQuery = [] + let memberChallengeIds + + // FIXME: This is wrong! + // if (!_.isUndefined(currentUser) && currentUser.handle) { + // accessQuery.push({ match_phrase: { createdBy: currentUser.handle } }) + // } + + if (criteria.memberId) { + // logger.error(`memberId ${criteria.memberId}`) + memberChallengeIds = await helper.listChallengesByMember(criteria.memberId) + // logger.error(`response ${JSON.stringify(ids)}`) + accessQuery.push({ terms: { _id: memberChallengeIds } }) + } + + if (accessQuery.length > 0) { + mustQuery.push({ + bool: { + should: accessQuery + } + }) + } + // FIXME: Tech Debt let excludeTasks = true // if you're an admin or m2m, security rules wont be applied @@ -369,6 +407,8 @@ async function searchChallenges (currentUser, criteria) { mustQuery.push({ bool: { should: [ + ...(_.get(memberChallengeIds, 'length', 0) > 0 ? [{ terms: { _id: memberChallengeIds } }] : []), + { bool: { must_not: { exists: { field: 'task.isTask' } } } }, { match_phrase: { 'task.isTask': false } }, { bool: { @@ -396,28 +436,6 @@ async function searchChallenges (currentUser, criteria) { }) } - const accessQuery = [] - - // FIXME: This is wrong! - // if (!_.isUndefined(currentUser) && currentUser.handle) { - // accessQuery.push({ match_phrase: { createdBy: currentUser.handle } }) - // } - - if (criteria.memberId) { - // logger.error(`memberId ${criteria.memberId}`) - const ids = await helper.listChallengesByMember(criteria.memberId) - // logger.error(`response ${JSON.stringify(ids)}`) - accessQuery.push({ terms: { _id: ids } }) - } - - if (accessQuery.length > 0) { - mustQuery.push({ - bool: { - should: accessQuery - } - }) - } - if (boolQuery.length > 0) { mustQuery.push({ bool: { @@ -465,7 +483,7 @@ async function searchChallenges (currentUser, criteria) { docs = await esClient.search(esQuery) } catch (e) { // Catch error when the ES is fresh and has no data - // logger.error(`Query Error from ES ${JSON.stringify(e)}`) + logger.error(`Query Error from ES ${JSON.stringify(e)}`) docs = { hits: { total: 0, @@ -573,7 +591,9 @@ searchChallenges.schema = { ids: Joi.array().items(Joi.optionalId()).unique().min(1), isTask: Joi.boolean(), taskIsAssigned: Joi.boolean(), - taskMemberId: Joi.string() + taskMemberId: Joi.string(), + events: Joi.array().items(Joi.number()), + includeAllEvents: Joi.boolean().default(true) }) } @@ -718,6 +738,12 @@ async function createChallenge (currentUser, challenge, userToken) { const { track, type } = await validateChallengeData(challenge) if (_.get(type, 'isTask')) { _.set(challenge, 'task.isTask', true) + if (_.isUndefined(_.get(challenge, 'task.isAssigned'))) { + _.set(challenge, 'task.isAssigned', false) + } + if (_.isUndefined(_.get(challenge, 'task.memberId'))) { + _.set(challenge, 'task.memberId', null) + } } if (challenge.phases && challenge.phases.length > 0) { await helper.validatePhases(challenge.phases) @@ -921,18 +947,12 @@ async function getChallenge (currentUser, id) { // } // delete challenge.typeId - // Check if challenge is task and apply security rules - if (_.get(challenge, 'task.isTask', false) && _.get(challenge, 'task.isAssigned', false)) { - if (!currentUser || (!currentUser.isMachine && !helper.hasAdminRole(currentUser) && _.toString(currentUser.userId) !== _.toString(_.get(challenge, 'task.memberId')))) { - throw new errors.ForbiddenError(`You don't have access to view this challenge`) - } - } - + let memberChallengeIds // Remove privateDescription for unregistered users if (currentUser) { if (!currentUser.isMachine) { - const ids = await helper.listChallengesByMember(currentUser.userId) - if (!_.includes(ids, challenge.id)) { + memberChallengeIds = await helper.listChallengesByMember(currentUser.userId) + if (!_.includes(memberChallengeIds, challenge.id)) { _.unset(challenge, 'privateDescription') } } @@ -940,6 +960,14 @@ async function getChallenge (currentUser, id) { _.unset(challenge, 'privateDescription') } + // Check if challenge is task and apply security rules + if (_.get(challenge, 'task.isTask', false) && _.get(challenge, 'task.isAssigned', false)) { + const canAccesChallenge = _.isUndefined(currentUser) ? false : _.includes((memberChallengeIds || []), challenge.id) || currentUser.isMachine || helper.hasAdminRole(currentUser) + if (!canAccesChallenge) { + throw new errors.ForbiddenError(`You don't have access to view this challenge`) + } + } + if (challenge.phases && challenge.phases.length > 0) { await getPhasesAndPopulate(challenge) }