UNPKG

topcoder-react-lib

Version:
874 lines (802 loc) 28.2 kB
/** * @module "services.challenges" * @desc This module provides a service for convenient manipulation with * Topcoder challenges via TC API. */ /* global CONFIG */ import _ from 'lodash'; import moment from 'moment'; import qs from 'qs'; import { decodeToken } from '@topcoder-platform/tc-auth-lib'; import logger from '../utils/logger'; import { setErrorIcon, ERROR_ICON_TYPES } from '../utils/errors'; import { COMPETITION_TRACKS, getApiResponsePayload } from '../utils/tc'; import { getApi } from './api'; import { getService as getMembersService } from './members'; import { getService as getSubmissionsService } from './submissions'; const { CHALLENGE_APP_VERSION } = CONFIG; export function getFilterUrl(backendFilter, frontFilter) { const ff = _.clone(frontFilter); // eslint-disable-next-line object-curly-newline const { tags, tracks, types, groups, events } = ff; delete ff.tags; delete ff.tracks; delete ff.types; delete ff.communityId; delete ff.groups; delete ff.events; // console.log(ff); let urlFilter = qs.stringify(_.reduce(ff, (result, value, key) => { // eslint-disable-next-line no-param-reassign if (value) result[key] = value; return result; }, {})); // console.log(urlFilter); const ftags = _.map(tags, val => `tags[]=${val}`).join('&'); const ftracks = _.map(_.reduce(tracks, (result, value, key) => { // eslint-disable-next-line no-unused-expressions tracks[key] && result.push(key); return result; }, []), val => `tracks[]=${val}`).join('&'); const ftypes = _.map(types, val => `types[]=${val}`).join('&'); const fgroups = _.map(groups, val => `groups[]=${val}`).join('&'); const fevents = _.map(events, val => `events[]=${val}`).join('&'); if (ftags.length > 0) urlFilter += `&${ftags}`; if (ftracks.length > 0) urlFilter += `&${ftracks}`; if (ftypes.length > 0) urlFilter += `&${ftypes}`; if (fgroups.length > 9) urlFilter += `&${fgroups}`; if (fevents.length > 0) urlFilter += `&${fevents}`; return urlFilter; } export const ORDER_BY = { SUBMISSION_END_DATE: 'submissionEndDate', }; /** * Normalizes a regular challenge object received from the backend. * NOTE: This function is copied from the existing code in the challenge listing * component. It is possible, that this normalization is not necessary after we * have moved to Topcoder API, but it is kept for now to minimize a risk of * breaking anything. * @todo Should be used only internally! * @param {Object} challenge Challenge object received from the backend. * @param {String} username Optional. */ export function normalizeChallenge(challenge, username) { const phases = challenge.allPhases || challenge.phases || []; const registration = phases.filter(d => d.name === 'Registration')[0]; let registrationOpen = 'No'; let registrationStartDate; let registrationEndDate; if (registration) { registrationStartDate = registration.actualStartDate || registration.scheduledStartDate; if (registration.isOpen) { registrationOpen = 'Yes'; } registrationEndDate = registration.actualEndDate || registration.scheduledEndDate; } const groups = {}; if (challenge.groups) { challenge.groups.forEach((id) => { groups[id] = true; }); } /* eslint-disable no-param-reassign */ if (!challenge.prizeSets) challenge.prizeSets = []; if (!challenge.tags) challenge.tags = []; if (!challenge.platforms) challenge.platforms = []; let submissionEndTimestamp = phases.filter(d => d.name === 'Submission')[0]; if (submissionEndTimestamp) { submissionEndTimestamp = submissionEndTimestamp.scheduledEndDate; } const placementPrizes = _.find(challenge.prizeSets, { type: 'placement' }); const prizes = _.get(placementPrizes, 'prizes', []); _.defaults(challenge, { communities: new Set([COMPETITION_TRACKS[challenge.track]]), groups, registrationOpen, submissionEndTimestamp, registrationStartDate, registrationEndDate, totalPrize: prizes.reduce((acc, prize) => acc + prize.value, 0), submissionEndDate: submissionEndTimestamp, users: username ? { [username]: true } : {}, }); } /** * Helper method that checks for HTTP error response and throws Error in this case. * @param {Object} res HTTP response object * @return {Object} API JSON response object * @private */ async function checkError(res) { if (!res.ok) { if (res.status >= 500) { setErrorIcon(ERROR_ICON_TYPES.API, '/challenges', res.statusText); } throw new Error(res.statusText); } const jsonRes = (await res.json()).result; if (jsonRes.status !== 200) throw new Error(jsonRes.content); return jsonRes; } /** * Helper method that checks for HTTP error response v5 and throws Error in this case. * @param {Object} res HTTP response object * @return {Object} API JSON response object * @private */ async function checkErrorV5(res) { if (!res.ok) { if (res.status >= 500) { setErrorIcon(ERROR_ICON_TYPES.API, '/challenges', res.statusText); } throw new Error((!res.statusText && res.status === 403) ? 'Forbidden' : res.statusText); } const jsonRes = (await res.json()); if (jsonRes.message) { throw new Error(res.message); } return { result: jsonRes, headers: res.headers, }; } /** * Challenge service. */ class ChallengesService { /** * Creates a new ChallengeService instance. * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. * @param {String} tokenV2 Optional. Auth token for Topcoder API v2. */ constructor(tokenV3, tokenV2) { /** * Private function being re-used in all methods related to getting * challenges. It handles query-related arguments in the uniform way: * @param {String} endpoint API endpoint, where the request will be send. * @param {Object} filters Optional. A map of filters to pass as `filter` * query parameter (this function takes care to stringify it properly). * @param {Object} params Optional. A map of any other parameters beside * `filter`. */ const getChallenges = async ( endpoint, filter, ) => { let res = {}; if (_.some(filter.frontFilter.tracks, val => val) && !_.isEqual(filter.frontFilter.types, [])) { const query = getFilterUrl(filter.backendFilter, filter.frontFilter); const url = `${endpoint}?${query}`; const options = { headers: { 'app-version': CHALLENGE_APP_VERSION } }; res = await this.private.apiV5.get(url, options).then(checkErrorV5); } return { challenges: res.result || [], totalCount: res.headers ? res.headers.get('x-total') : 0, meta: { allChallengesCount: res.headers ? res.headers.get('x-total') : 0, allRecommendedChallengesCount: 0, myChallengesCount: 0, ongoingChallengesCount: 0, openChallengesCount: 0, totalCount: res.headers ? res.headers.get('x-total') : 0, }, }; }; const getChallengeDetails = async ( endpoint, legacyInfo, ) => { let query = ''; if (legacyInfo) { query = `legacyId=${legacyInfo.legacyId}`; } const url = `${endpoint}?${query}`; const options = { headers: { 'app-version': CHALLENGE_APP_VERSION } }; const res = await this.private.apiV5.get(url, options).then(checkErrorV5); return { challenges: res.result || [], }; }; /** * Private function being re-used in all methods related to getting * challenges. It handles query-related arguments in the uniform way: * @param {String} endpoint API endpoint, where the request will be send. * @param {Object} filters Optional. A map of filters to pass as `filter` * query parameter (this function takes care to stringify it properly). * @param {Object} params Optional. A map of any other parameters beside * `filter`. */ const getMemberChallenges = async ( endpoint, filters = {}, params = {}, ) => { const memberId = decodeToken(this.private.tokenV3).userId; const query = { ...params, ...filters, memberId, }; const url = `${endpoint}?${qs.stringify(_.omit(query, ['limit', 'offset', 'technologies']))}`; const options = { headers: { 'app-version': CHALLENGE_APP_VERSION } }; const res = await this.private.apiV5.get(url, options).then(checkError); const totalCount = res.length; return { challenges: res || [], totalCount, }; }; this.private = { api: getApi('V4', tokenV3), apiV5: getApi('V5', tokenV3), apiV2: getApi('V2', tokenV2), apiV3: getApi('V3', tokenV3), getChallenges, getChallengeDetails, getMemberChallenges, tokenV2, tokenV3, memberService: getMembersService(), submissionsService: getSubmissionsService(tokenV3), }; } /** * Gets challenge statistics. * @param {Number} challengeId * @return {Promise} The array of statistics */ async getChallengeStatistics(challengeId) { return this.private.apiV5.get(`/challenges/${challengeId}/statistics`) .then(res => (res.ok ? res.json() : new Error(res.statusText))) .then(res => ( res.message ? new Error(res.message) : res )); } /** * Activates the specified challenge. * @param {Number} challengeId * @return {Promise} Resolves to null value in case of success; otherwise it * is rejected. */ async activate(challengeId) { const params = { status: 'Active', }; let res = await this.private.apiV5.patch(`/challenge/${challengeId}`, params); if (!res.ok) throw new Error(res.statusText); res = (await res.json()).result; if (res.status !== 200) throw new Error(res.content); return res.content; } /** * Closes the specified challenge. * @param {Number} challengeId * @return {Promise} Resolves to null value in case of success; otherwise it * is rejected. */ async close(challengeId) { const params = { status: 'Completed', }; let res = await this.private.apiV5.patch(`/challenges/${challengeId}`, params); if (!res.ok) throw new Error(res.statusText); res = (await res.json()).result; if (res.status !== 200) throw new Error(res.content); return res.content; } /** * Creates a new payment task. * @param {Number} projectId * @param {Number} accountId Billing account ID. * @param {String} title * @param {String} description * @param {String} assignee * @param {Number} payment * @param {String} submissionGuidelines * @param {Number} copilotId * @param {Number} copilotFee * @param {?} tags * @return {Promise} Resolves to the created challenge object (payment task). */ async createTask( projectId, accountId, title, description, assignee, payment, submissionGuidelines, copilotId, copilotFee, tags, ) { const registrationPhase = await this.private.apiV5.get('/challenge-phases?name=Registration'); const payload = { param: { name: title, typeId: 'e885273d-aeda-42c0-917d-bfbf979afbba', description, legacy: { track: 'FIRST_2_FINISH', reviewType: 'INTERNAL', confidentialityType: 'public', billingAccountId: accountId, }, phases: [ { phaseId: registrationPhase.id, scheduledEndDate: moment().toISOString(), }, ], prizeSets: [ { type: 'Challenge Prizes', description: 'Challenge Prize', prizes: [ { value: payment, type: 'First Placement', }, ], }, ], tags, projectId, }, }; if (copilotId) { _.assign(payload.param, { copilotId, copilotFee, }); } let res = await this.private.apiV5.postJson('/challenges', payload); if (!res.ok) throw new Error(res.statusText); res = (await res.json()).result; if (res.status !== 200) throw new Error(res.content); return res.content; } /** * Gets challenge details from Topcoder API. * NOTE: This function also uses API v2 and other endpoints for now, due * to some information is missing or * incorrect in the main endpoint. This may change in the future. * @param {Number|String} challengeId * @return {Promise} Resolves to the challenge object. */ async getChallengeDetails(challengeId) { const memberId = this.private.tokenV3 ? decodeToken(this.private.tokenV3).userId : null; let challenge = {}; let registrants = []; let submissions = []; let isLegacyChallenge = false; let isRegistered = false; const userDetails = { roles: [] }; // condition based on ROUTE used for Review Opportunities, change if needed if (/^[\d]{5,8}$/.test(challengeId)) { isLegacyChallenge = true; challenge = await this.private.getChallengeDetails('/challenges/', { legacyId: challengeId }) .then(res => res.challenges[0] || {}); } else { challenge = await this.private.getChallengeDetails(`/challenges/${challengeId}`) .then(res => res.challenges); } if (challenge) { registrants = await this.getChallengeRegistrants(challenge.id); // This TEMP fix to colorStyle, this will be fixed with issue #4530 registrants = _.map(registrants, r => ({ ...r, colorStyle: 'color: #151516', })); /* Prepare data to logged user */ if (memberId) { isRegistered = _.some(registrants, r => `${r.memberId}` === `${memberId}`); const subParams = { challengeId, perPage: 100, }; submissions = await this.private.submissionsService.getSubmissions(subParams); if (submissions) { // Remove AV Scan, SonarQube Review and Virus Scan review types const reviewScans = await this.private.submissionsService.getScanReviewIds(); submissions.forEach((s, i) => { submissions[i].review = _.reject(s.review, r => r && _.includes(reviewScans, r.typeId)); }); // Add submission date to registrants registrants.forEach((r, i) => { const submission = submissions.find(s => `${s.memberId}` === `${r.memberId}`); if (submission) { registrants[i].submissionDate = submission.created; } }); } userDetails.roles = await this.getUserRolesInChallenge(challengeId); } challenge = { ...challenge, isLegacyChallenge, isRegistered, registrants, submissions, userDetails, events: _.map(challenge.events, e => ({ eventName: e.key, eventId: e.id, description: e.name, })), fetchedWithAuth: Boolean(this.private.apiV5.private.token), }; } return challenge; } /** * Gets challenge registrants from Topcoder API. * @param {Number|String} challengeId * @return {Promise} Resolves to the challenge registrants array. */ async getChallengeRegistrants(challengeId) { /* If no token provided, resource will return Submitter role only */ const roleId = this.private.tokenV3 ? await this.getRoleId('Submitter') : null; let params = { challengeId, perPage: 5000, }; if (roleId) { params = { ...params, roleId }; } let registrants = await this.private.apiV5.get(`/resources?${qs.stringify(params)}`) .then(checkErrorV5).then(res => res.result); /* API will return all roles to currentUser, so need to filter in FE */ if (roleId) { registrants = _.filter(registrants, r => r.roleId === roleId); } return registrants || []; } /** * Gets possible challenge types. * @return {Promise} Resolves to the array of subtrack names. */ getChallengeTypes() { return this.private.apiV5.get('/challenge-types') .then(res => (res.ok ? res.json() : new Error(res.statusText))) .then(res => ( res.message ? new Error(res.message) : res )); } /** * Get the ID from a challenge type by abbreviation * @param {String} abbreviation * @return {Promise} ID from first abbreviation match */ async getChallengeTypeId(abbreviation) { const ret = await this.private.apiV5.get(`/challenge-types?abbreviation=${abbreviation}`) .then(checkErrorV5).then(res => res); if (_.isEmpty(ret.result)) { throw new Error('Challenge typeId not found!'); } return ret.result[0].id; } /** * Gets possible challenge tags (technologies). * @return {Promise} Resolves to the array of tag strings. */ getChallengeTags() { return this.private.api.get('/technologies') .then(res => (res.ok ? res.json() : new Error(res.statusText))) .then(res => ( res.result.status === 200 ? res.result.content : new Error(res.result.content) )); } /** * Gets challenges. * @param {Object} filters Optional. * @param {Object} params Optional. * @return {Promise} Resolves to the api response. */ async getChallenges(filter) { return this.private.getChallenges('/challenges/', filter) .then((res) => { res.challenges.forEach(item => normalizeChallenge(item)); return res; }); } /** * Gets challenges. * @param {Object} filters Optional. * @param {Object} params Optional. * @param {String} handle user handle * @return {Promise} Resolves to the api response. */ async getRecommendedChallenges(filter, handle) { filter.frontFilter.per_page = filter.frontFilter.perPage; delete filter.frontFilter.perPage; const query = getFilterUrl(filter.backendFilter, filter.frontFilter); let res = {}; let totalCount = 0; if (_.some(filter.frontFilter.tracks, val => val) && !_.isEqual(filter.frontFilter.types, [])) { const url = `/recommender-api/${handle}?${query}`; const options = { headers: { 'app-version': CHALLENGE_APP_VERSION } }; res = await this.private.apiV5.get(url, options).then(checkErrorV5); totalCount = res.headers.get('x-total') || 0; } const challenges = res.result ? res.result.filter(ch => ch.bucket) : []; return { challenges, totalCount, meta: { allChallengesCount: totalCount, allRecommendedChallengesCount: 0, myChallengesCount: 0, ongoingChallengesCount: 0, openChallengesCount: 0, totalCount, }, }; } /** * Gets SRM matches. * @param {Object} params * @return {Promise} */ async getSrms(params) { const res = await this.private.api.get(`/srms/?${qs.stringify(params)}`); return getApiResponsePayload(res); } static updateFiltersParamsForGettingMemberChallenges(filters, params) { if (params && params.perPage) { // eslint-disable-next-line no-param-reassign params.offset = (params.page - 1) * params.perPage; // eslint-disable-next-line no-param-reassign params.limit = params.perPage; } } /** * Gets challenges of the specified user. * @param {String} userId User id whose challenges we want to fetch. * @return {Promise} Resolves to the api response. */ async getUserChallenges(userId, filters, params) { const userFilters = _.cloneDeep(filters); ChallengesService.updateFiltersParamsForGettingMemberChallenges(userFilters, params); const query = { ...params, ...userFilters, memberId: userId, }; const url = `/challenges?${qs.stringify(_.omit(query, ['limit', 'offset', 'technologies']))}`; const options = { headers: { 'app-version': CHALLENGE_APP_VERSION } }; const userChallenges = await this.private.apiV5.get(url, options) .then(checkErrorV5) .then((res) => { res.result.forEach(item => normalizeChallenge(item, userId)); return res.result; }); return { challenges: userChallenges, totalCount: userChallenges.length, }; } /** * Gets challenges of the specified user from v4 api. * @param {String} username User name whose challenges we want to fetch. * @return {Promise} Resolves to the api response. */ async getUserChallengesV4(username, filters, params) { const endpoint = `/members/${username.toLowerCase()}/challenges/`; const query = { filter: qs.stringify(filters, { encode: false }), ...params, }; const url = `${endpoint}?${qs.stringify(query)}`; const res = await this.private.api.get(url).then(checkError); return { challenges: res.content || [], totalCount: res.metadata.totalCount, }; } /** * Gets user resources. * @param {String} userId User id whose challenges we want to fetch. * @param {Number} page Current page for paginated API response (default 1) * @param {Number} perPage Page size for paginated API response (default 1000) * @return {Promise} Resolves to the api response. */ async getUserResources(userId, page = 1, perPage = 1000) { const res = await this.private.apiV5.get(`/resources/${userId}/challenges?page=${page}&perPage=${perPage}`); return res.json(); } /** * Gets marathon matches of the specified user. * @param {String} memberId User whose challenges we want to fetch. * @param {Object} filter * @param {Object} params * @return {Promise} Resolves to the api response. */ async getUserMarathonMatches(memberId, filter, params) { const newParams = { ...filter, ...params, tag: 'Marathon Match', memberId, }; const options = { headers: { 'app-version': CHALLENGE_APP_VERSION } }; const res = await this.private.apiV5.get(`/challenges?${qs.stringify(newParams)}`, options); return res.json(); } /** * Gets SRM matches related to the user. * @param {String} handle * @param {Object} params * @return {Promise} */ async getUserSrms(handle, params) { const url = `/members/${handle}/srms/?${qs.stringify(params)}`; const options = { headers: { 'app-version': CHALLENGE_APP_VERSION } }; const res = await this.private.api.get(url, options); return getApiResponsePayload(res); } /** * Get the Resource Role ID from provided Role Name * @param {String} roleName * @return {Promise} */ async getRoleId(roleName) { const params = { name: roleName, isActive: true, }; const roles = await this.private.apiV5.get(`/resource-roles?${qs.stringify(params)}`) .then(checkErrorV5).then(res => res.result); if (_.isEmpty(roles)) { throw new Error('Resource Role not found!'); } return roles[0].id; } /** * Registers user to the specified challenge. * @param {String} challengeId * @return {Promise} */ async register(challengeId) { const user = decodeToken(this.private.tokenV3); const roleId = await this.getRoleId('Submitter'); const params = { challengeId, memberHandle: encodeURIComponent(user.handle), roleId, }; const res = await this.private.apiV5.postJson('/resources', params); if (!res.ok) throw new Error(res.statusText); return res.json(); } /** * Unregisters user from the specified challenge. * @param {String} challengeId * @return {Promise} */ async unregister(challengeId) { const user = decodeToken(this.private.tokenV3); const roleId = await this.getRoleId('Submitter'); const params = { challengeId, memberHandle: encodeURIComponent(user.handle), roleId, }; const res = await this.private.apiV5.delete('/resources', JSON.stringify(params)); if (!res.ok) throw new Error(res.statusText); return res.json(); } /** * Gets count of user's active challenges. * @param {String} handle Topcoder user handle. * @return {Action} Resolves to the api response. */ getActiveChallengesCount(handle) { const filter = { status: 'Active' }; const params = { limit: 1, offset: 0 }; return this.getUserChallenges(handle, filter, params).then(res => res.totalCount); } /** * Submits a challenge submission. Uses APIV2 for Development submission * and APIV3 for Design submisisons. * @param {Object} body * @param {String} challengeId * @param {String} track Either DESIGN or DEVELOP * @return {Promise} */ submit(body, challengeId, track, onProgress) { let api; let contentType; let url; if (track === COMPETITION_TRACKS.DES) { ({ api } = this.private); contentType = 'application/json'; url = '/submissions/'; // The submission info is contained entirely in the JSON body } else { api = this.private.apiV2; // contentType = 'multipart/form-data'; contentType = null; url = `/develop/challenges/${challengeId}/upload`; } return api.upload(url, { body, headers: { 'Content-Type': contentType }, method: 'POST', }, onProgress).then((res) => { const jres = JSON.parse(res); // Return result for Develop submission if (track === COMPETITION_TRACKS.DEV) { return jres; } // Design Submission requires an extra "Processing" POST const procId = jres.result.content.id; return api.upload(`/submissions/${procId}/process/`, { body: JSON.stringify({ param: jres.result.content }), headers: { 'Content-Type': contentType }, method: 'POST', }, onProgress).then(procres => JSON.parse(procres)); }, (err) => { logger.error(`Failed to submit to the challenge #${challengeId}`, err); throw err; }); } /** * Updates the challenge (saves the give challenge to the API). * @param {Object} challenge * @param {String} tokenV3 * @return {Promise} */ async updateChallenge(challenge) { const url = `/challenges/${challenge.id}`; let res = await this.private.apiV5.put(url, challenge); if (!res.ok) throw new Error(res.statusText); res = (await res.json()).result; if (res.status !== 200) throw new Error(res.content); return res.content; } /** * Gets roles of a user in the specified challenge. The user tested is * the owner of authentication token used to instantiate the service. * * Notice, if you have already loaded the challenge as that user, these roles * are attached to the challenge object under `userDetails.roles` path during * challenge normalization. However, if you have not, this method is the most * efficient way to get them, as it by-passes any unnecessary normalizations * of the challenge object. * * @param {Number} challengeId Challenge ID. */ async getUserRolesInChallenge(challengeId) { const user = decodeToken(this.private.tokenV3); const url = `/resources?challengeId=${challengeId}&memberHandle=${user.handle}`; const getResourcesResponse = await this.private.apiV5.get(url); const resources = await getResourcesResponse.json(); if (resources) return _.map(_.filter(resources, r => r.memberHandle === user.handle), 'roleId'); throw new Error(`Failed to fetch user role from challenge #${challengeId}`); } } let lastInstance = null; /** * Returns a new or existing challenges service. * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. * @param {String} tokenV2 Optional. Auth token for Topcoder API v2. * @return {ChallengesService} Challenges service object */ export function getService(tokenV3, tokenV2) { if (!lastInstance || lastInstance.private.tokenV3 !== tokenV3 || lastInstance.tokenV2 !== tokenV2) { lastInstance = new ChallengesService(tokenV3, tokenV2); } return lastInstance; } /* Using default export would be confusing in this case. */ export default undefined;