diff --git a/__tests__/__snapshots__/index.js.snap b/__tests__/__snapshots__/index.js.snap index 337071e0..283e8d01 100644 --- a/__tests__/__snapshots__/index.js.snap +++ b/__tests__/__snapshots__/index.js.snap @@ -12,6 +12,8 @@ Object { "challenge": Object { "dropCheckpoints": [Function], "dropResults": [Function], + "fetchChallengeStatisticsDone": [Function], + "fetchChallengeStatisticsInit": [Function], "fetchCheckpointsDone": [Function], "fetchCheckpointsInit": [Function], "getActiveChallengesCountDone": [Function], diff --git a/__tests__/actions/profile.js b/__tests__/actions/profile.js index 4ea366a3..b4d1848b 100644 --- a/__tests__/actions/profile.js +++ b/__tests__/actions/profile.js @@ -18,6 +18,8 @@ const linkedAccounts = [{ // Mock services const mockMembersService = { + getPresignedUrl: jest.fn().mockReturnValue(Promise.resolve()), + uploadFileToS3: jest.fn().mockReturnValue(Promise.resolve()), updateMemberPhoto: jest.fn().mockReturnValue(Promise.resolve('url-of-photo')), updateMemberProfile: jest.fn().mockReturnValue(Promise.resolve(profile)), addSkill: jest.fn().mockReturnValue(Promise.resolve({ skills: [skill] })), @@ -45,6 +47,8 @@ test('Module exports', () => expect(actions).toMatchSnapshot()); test('profile.uploadPhotoDone', async () => { const actionResult = await redux.resolveAction(actions.profile.uploadPhotoDone(handle, tokenV3)); expect(actionResult).toMatchSnapshot(); + expect(mockMembersService.getPresignedUrl).toBeCalled(); + expect(mockMembersService.uploadFileToS3).toBeCalled(); expect(mockMembersService.updateMemberPhoto).toBeCalled(); }); diff --git a/__tests__/reducers/__snapshots__/challenge.js.snap b/__tests__/reducers/__snapshots__/challenge.js.snap index 009e5691..e744b7c2 100644 --- a/__tests__/reducers/__snapshots__/challenge.js.snap +++ b/__tests__/reducers/__snapshots__/challenge.js.snap @@ -15,6 +15,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -40,6 +41,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -65,6 +67,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -87,6 +90,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -118,6 +122,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -151,6 +156,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -182,6 +188,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -211,6 +218,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -243,6 +251,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -276,6 +285,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -309,6 +319,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -342,6 +353,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -375,6 +387,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -406,6 +419,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -439,6 +453,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -470,6 +485,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -499,6 +515,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -531,6 +548,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -552,6 +570,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -577,6 +596,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -602,6 +622,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -624,6 +645,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -655,6 +677,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -688,6 +711,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -719,6 +743,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -748,6 +773,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -780,6 +806,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -801,6 +828,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -826,6 +854,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -851,6 +880,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -873,6 +903,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -904,6 +935,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -937,6 +969,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -968,6 +1001,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -997,6 +1031,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", @@ -1029,6 +1064,7 @@ Object { "registering": false, "results": null, "resultsLoadedForChallengeId": "", + "statisticsData": null, "submissionInformation": null, "unregistering": false, "updatingChallengeUuid": "", diff --git a/docs/services.members.md b/docs/services.members.md index 8fe477f2..856b90ce 100644 --- a/docs/services.members.md +++ b/docs/services.members.md @@ -25,7 +25,9 @@ members via API V3. * [.addSkill(handle, skillTagId)](#module_services.members..MembersService+addSkill) ⇒ Promise * [.hideSkill(handle, skillTagId)](#module_services.members..MembersService+hideSkill) ⇒ Promise * [.updateMemberProfile(profile)](#module_services.members..MembersService+updateMemberProfile) ⇒ Promise - * [.updateMemberPhoto(userHandle, file)](#module_services.members..MembersService+updateMemberPhoto) ⇒ Promise + * [.getPresignedUrl(userHandle, file)](#module_services.members..MembersService+getPresignedUrl) ⇒ Promise + * [.updateMemberPhoto(S3Response)](#module_services.members..MembersService+updateMemberPhoto) ⇒ Promise + * [.uploadFileToS3(presignedUrlResponse)](#module_services.members..MembersService+uploadFileToS3) ⇒ Promise * [.verifyMemberNewEmail(handle, emailVerifyToken)](#module_services.members..MembersService+verifyMemberNewEmail) ⇒ Promise @@ -63,7 +65,9 @@ Service class. * [.addSkill(handle, skillTagId)](#module_services.members..MembersService+addSkill) ⇒ Promise * [.hideSkill(handle, skillTagId)](#module_services.members..MembersService+hideSkill) ⇒ Promise * [.updateMemberProfile(profile)](#module_services.members..MembersService+updateMemberProfile) ⇒ Promise - * [.updateMemberPhoto(userHandle, file)](#module_services.members..MembersService+updateMemberPhoto) ⇒ Promise + * [.getPresignedUrl(userHandle, file)](#module_services.members..MembersService+getPresignedUrl) ⇒ Promise + * [.updateMemberPhoto(S3Response)](#module_services.members..MembersService+updateMemberPhoto) ⇒ Promise + * [.uploadFileToS3(presignedUrlResponse)](#module_services.members..MembersService+uploadFileToS3) ⇒ Promise * [.verifyMemberNewEmail(handle, emailVerifyToken)](#module_services.members..MembersService+verifyMemberNewEmail) ⇒ Promise @@ -252,10 +256,10 @@ Updates member profile. | --- | --- | --- | | profile | Object | The profile to update. | - + -#### membersService.updateMemberPhoto(userHandle, file) ⇒ Promise -Uploads and updates member photo. +#### membersService.getPresignedUrl(userHandle, file) ⇒ Promise +Gets presigned url for member photo file. **Kind**: instance method of [MembersService](#module_services.members..MembersService) **Returns**: Promise - Resolves to the api response content @@ -263,7 +267,31 @@ Uploads and updates member photo. | Param | Type | Description | | --- | --- | --- | | userHandle | String | The user handle | -| file | File | The file to be uploaded | +| file | File | The file to get its presigned url | + + + +#### membersService.updateMemberPhoto(S3Response) ⇒ Promise +Updates member photo. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| S3Response | Object | The response from uploadFileToS3() function. | + + + +#### membersService.uploadFileToS3(presignedUrlResponse) ⇒ Promise +Uploads file to S3. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| presignedUrlResponse | Object | The presigned url response from getPresignedUrl() function. | diff --git a/package.json b/package.json index 7ab753e2..442f898e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint:js": "./node_modules/.bin/eslint --ext .js,.jsx .", "test": "npm run lint && npm run jest" }, - "version": "1.2.3", + "version": "1.2.4", "dependencies": { "auth0-js": "^6.8.4", "config": "^3.2.0", diff --git a/src/actions/challenge.js b/src/actions/challenge.js index 86655807..9a1c048f 100644 --- a/src/actions/challenge.js +++ b/src/actions/challenge.js @@ -392,6 +392,25 @@ function getSubmissionInformationDone(challengeId, submissionId, tokenV3) { }); } +/** + * @static + * @desc Creates an action that signals beginning of fetching challenge statistics + * @return {Action} + */ +function fetchChallengeStatisticsInit() {} + +/** + * @static + * @desc Creates an action that gets challenge statistics from the backend. + * @param {String} challengeId The challenge id + * @param {String} tokenV3 Topcoder auth token v3. + * @return {Action} + */ +function fetchChallengeStatisticsDone(challengeId, tokenV3) { + const challengeService = getChallengesService(tokenV3); + return challengeService.getChallengeStatistics(challengeId); +} + export default createActions({ CHALLENGE: { DROP_CHECKPOINTS: dropCheckpoints, @@ -417,5 +436,7 @@ export default createActions({ GET_MM_SUBMISSIONS_DONE: getMMSubmissionsDone, GET_SUBMISSION_INFORMATION_INIT: getSubmissionInformationInit, GET_SUBMISSION_INFORMATION_DONE: getSubmissionInformationDone, + FETCH_CHALLENGE_STATISTICS_INIT: fetchChallengeStatisticsInit, + FETCH_CHALLENGE_STATISTICS_DONE: fetchChallengeStatisticsDone, }, }); diff --git a/src/actions/profile.js b/src/actions/profile.js index 96a9ef5e..bcd668cf 100644 --- a/src/actions/profile.js +++ b/src/actions/profile.js @@ -216,7 +216,9 @@ function uploadPhotoInit() {} */ function uploadPhotoDone(handle, tokenV3, file) { const service = getMembersService(tokenV3); - return service.updateMemberPhoto(handle, file) + return service.getPresignedUrl(handle, file) + .then(res => service.uploadFileToS3(res)) + .then(res => service.updateMemberPhoto(res)) .then(photoURL => ({ handle, photoURL })); } diff --git a/src/reducers/challenge.js b/src/reducers/challenge.js index 7dde8670..db116631 100644 --- a/src/reducers/challenge.js +++ b/src/reducers/challenge.js @@ -368,6 +368,26 @@ function onGetSubmissionInformationDone(state, action) { }; } +/** + * Handles CHALLENGE/GET_CHALLENGE_STATISTICS_DONE action. + * @param {Object} state Previous state. + * @param {Object} action Action. + */ +function onFetchChallengeStatisticsDone(state, action) { + if (action.error) { + logger.error('Failed to get challenge statistics', action.payload); + return { + ...state, + statisticsData: null, + }; + } + + return { + ...state, + statisticsData: action.payload, + }; +} + /** * Creates a new Challenge reducer with the specified initial state. * @param {Object} initialState Optional. Initial state. @@ -411,6 +431,8 @@ function create(initialState) { [a.getActiveChallengesCountDone]: onGetActiveChallengesCountDone, [a.getSubmissionInformationInit]: onGetSubmissionInformationInit, [a.getSubmissionInformationDone]: onGetSubmissionInformationDone, + [a.fetchChallengeStatisticsInit]: state => state, + [a.fetchChallengeStatisticsDone]: onFetchChallengeStatisticsDone, }, _.defaults(initialState, { details: null, loadingCheckpoints: false, @@ -427,6 +449,7 @@ function create(initialState) { updatingChallengeUuid: '', mmSubmissions: [], submissionInformation: null, + statisticsData: null, })); } diff --git a/src/services/challenges.js b/src/services/challenges.js index bf2efe20..38ae8c5e 100644 --- a/src/services/challenges.js +++ b/src/services/challenges.js @@ -253,6 +253,21 @@ class ChallengesService { }; } + /** + * 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 diff --git a/src/services/members.js b/src/services/members.js index 61f3e9ab..7b6648b0 100644 --- a/src/services/members.js +++ b/src/services/members.js @@ -4,10 +4,11 @@ * members via API V3. */ -/* global FormData */ +/* global XMLHttpRequest */ import _ from 'lodash'; import qs from 'qs'; import { decodeToken } from '@topcoder-platform/tc-auth-lib'; +import logger from '../utils/logger'; import { getApiResponsePayload, handleApiResponse } from '../utils/tc'; import { getApi } from './api'; @@ -238,23 +239,72 @@ class MembersService { } /** - * Updates member photo. + * Gets presigned url for member photo file. * @param {String} userHandle The user handle - * @param {File} file The photo to upload + * @param {File} file The file to get its presigned url * @return {Promise} Resolves to the api response content */ - async updateMemberPhoto(userHandle, file) { - const formData = new FormData(); - formData.append('photo', file); - const res = await this.private.apiV5.fetch(`/members/${userHandle}/photo`, { - method: 'POST', - headers: { - 'Content-Type': null, - }, - body: formData, + async getPresignedUrl(userHandle, file) { + const res = await this.private.api.postJson(`/members/${userHandle}/photoUploadUrl`, { param: { contentType: file.type } }); + const payload = await getApiResponsePayload(res); + + return { + preSignedURL: payload.preSignedURL, + token: payload.token, + file, + userHandle, + }; + } + + /** + * Updates member photo. + * @param {Object} S3Response The response from uploadFileToS3() function. + * @return {Promise} Resolves to the api response content + */ + async updateMemberPhoto(S3Response) { + const res = await this.private.api.putJson(`/members/${S3Response.userHandle}/photo`, { param: S3Response.body }); + return getApiResponsePayload(res); + } + + /** + * Uploads file to S3. + * @param {Object} presignedUrlResponse The presigned url response from + * getPresignedUrl() function. + * @return {Promise} Resolves to the api response content + */ + uploadFileToS3(presignedUrlResponse) { + _.noop(this); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('PUT', presignedUrlResponse.preSignedURL, true); + xhr.setRequestHeader('Content-Type', presignedUrlResponse.file.type); + + xhr.onreadystatechange = () => { + const { status } = xhr; + if (((status >= 200 && status < 300) || status === 304) && xhr.readyState === 4) { + resolve({ + userHandle: presignedUrlResponse.userHandle, + body: { + token: presignedUrlResponse.token, + contentType: presignedUrlResponse.file.type, + }, + }); + } else if (status >= 400) { + const err = new Error('Could not upload image to S3'); + err.status = status; + reject(err); + } + }; + + xhr.onerror = (err) => { + logger.error('Could not upload image to S3', err); + + reject(err); + }; + + xhr.send(presignedUrlResponse.file); }); - return handleApiResponse(res) - .then(({ photoURL }) => photoURL); } /** diff --git a/src/services/user-traits.js b/src/services/user-traits.js index 34d25a26..c1d71b71 100644 --- a/src/services/user-traits.js +++ b/src/services/user-traits.js @@ -4,7 +4,7 @@ * via API V3. */ import toCapitalCase from 'to-capital-case'; -import { handleApiResponse } from '../utils/tc'; +import { getApiResponsePayload } from '../utils/tc'; import { getApi } from './api'; /** @@ -16,7 +16,7 @@ class UserTraitsService { */ constructor(tokenV3) { this.private = { - api: getApi('V5', tokenV3), + api: getApi('V3', tokenV3), tokenV3, }; } @@ -29,7 +29,7 @@ class UserTraitsService { async getAllUserTraits(handle) { // FIXME: Remove the .toLowerCase() when the API is fixed to ignore the case in the route params const res = await this.private.api.get(`/members/${handle.toLowerCase()}/traits`); - return handleApiResponse(res); + return getApiResponsePayload(res); } /** @@ -40,16 +40,18 @@ class UserTraitsService { * @return {Promise} Resolves to the member traits. */ async addUserTrait(handle, traitId, data) { - const body = [{ - traitId, - categoryName: toCapitalCase(traitId), - traits: { - data, - }, - }]; + const body = { + param: [{ + traitId, + categoryName: toCapitalCase(traitId), + traits: { + data, + }, + }], + }; const res = await this.private.api.postJson(`/members/${handle}/traits`, body); - return handleApiResponse(res); + return getApiResponsePayload(res); } /** @@ -60,16 +62,18 @@ class UserTraitsService { * @return {Promise} Resolves to the member traits. */ async updateUserTrait(handle, traitId, data) { - const body = [{ - traitId, - categoryName: toCapitalCase(traitId), - traits: { - data, - }, - }]; + const body = { + param: [{ + traitId, + categoryName: toCapitalCase(traitId), + traits: { + data, + }, + }], + }; const res = await this.private.api.putJson(`/members/${handle}/traits`, body); - return handleApiResponse(res); + return getApiResponsePayload(res); } /** @@ -80,7 +84,7 @@ class UserTraitsService { */ async deleteUserTrait(handle, traitId) { const res = await this.private.api.delete(`/members/${handle}/traits?traitIds=${traitId}`); - return handleApiResponse(res); + return getApiResponsePayload(res); } } diff --git a/src/utils/tc.js b/src/utils/tc.js index ff6d46f1..2941de37 100644 --- a/src/utils/tc.js +++ b/src/utils/tc.js @@ -87,8 +87,7 @@ export async function getApiResponsePayload(res, shouldThrowError = true) { */ export function handleApiResponse(response) { if (!response.ok) throw new Error(response.statusText); - return response.json() - .catch(() => null); + return response.json(); } /**