diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index c414fbce..8dd29cf4 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -70,5 +70,11 @@ "EMBED_REPORTS_MAPPING": "EMBED_REPORTS_MAPPING", "ALLOWED_USERS": "REPORTS_ALLOWED_USERS" }, - "DEFAULT_M2M_USERID": "DEFAULT_M2M_USERID" + "DEFAULT_M2M_USERID": "DEFAULT_M2M_USERID", + "salesforce": { + "CLIENT_AUDIENCE": "SALESFORCE_AUDIENCE", + "CLIENT_KEY": "SALESFORCE_CLIENT_KEY", + "SUBJECT": "SALESFORCE_SUBJECT", + "CLIENT_ID": "SALESFORCE_CLIENT_ID" + } } diff --git a/config/default.json b/config/default.json index b302a52f..c67a8bdb 100644 --- a/config/default.json +++ b/config/default.json @@ -76,5 +76,11 @@ "ALLOWED_USERS": "[]" }, "DEFAULT_M2M_USERID": -101, - "taasJobApiUrl": "https://api.topcoder.com/v5/jobs" + "taasJobApiUrl": "https://api.topcoder.com/v5/jobs", + "salesforce": { + "CLIENT_KEY": "", + "CLIENT_AUDIENCE": "", + "SUBJECT": "", + "CLIENT_ID": "" + } } diff --git a/docs/Project API.postman_collection.json b/docs/Project API.postman_collection.json index 8035b068..71167a99 100644 --- a/docs/Project API.postman_collection.json +++ b/docs/Project API.postman_collection.json @@ -925,6 +925,55 @@ }, "response": [] }, + { + "name": "Create project member with no payload and return member details", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"memberId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/members?fields=id,userId,role,isPrimary,deletedAt,createdAt,updatedAt,deletedBy,createdBy,updatedBy,handle,photoURL,workingHourStart,workingHourEnd,timeZone,email", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "members" + ], + "query": [ + { + "key": "fields", + "value": "id,userId,role,isPrimary,deletedAt,createdAt,updatedAt,deletedBy,createdBy,updatedBy,handle,photoURL,workingHourStart,workingHourEnd,timeZone,email" + } + ] + }, + "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." + }, + "response": [] + }, { "name": "Update project member", "request": { diff --git a/docs/permissions.html b/docs/permissions.html index b494f485..cbd762a4 100644 --- a/docs/permissions.html +++ b/docs/permissions.html @@ -375,6 +375,43 @@

+
+
+

+ Project Billing Accounts +

+
+
+
+
+
+ Read Available Project Billing Accounts +
+
READ_AVL_PROJECT_BILLING_ACCOUNTS
+
Who can view the Billing Accounts available for the project
+
+
+
+ manager + account_manager + program_manager + account_executive + solution_architect + project_manager + copilot +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + +
+
+

diff --git a/local/postgres-db/Dockerfile b/local/postgres-db/Dockerfile index 844b3f09..acc88fc7 100644 --- a/local/postgres-db/Dockerfile +++ b/local/postgres-db/Dockerfile @@ -1,2 +1,2 @@ -FROM postgres:9.5 -COPY create-multiple-postgresql-databases.sh /docker-entrypoint-initdb.d/ \ No newline at end of file +FROM postgres:12.3 +COPY create-multiple-postgresql-databases.sh /docker-entrypoint-initdb.d/ diff --git a/src/constants.js b/src/constants.js index 97b0371f..6fe0011b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -274,7 +274,8 @@ export const M2M_SCOPES = { ALL: 'all:projects', READ: 'read:projects', WRITE: 'write:projects', - WRITE_BILLING_ACCOUNTS: 'write:projects-billing-accounts', + READ_USER_BILLING_ACCOUNTS: 'read:user-billing-accounts', + WRITE_PROJECTS_BILLING_ACCOUNTS: 'write:projects-billing-accounts', }, PROJECT_MEMBERS: { ALL: 'all:project-members', diff --git a/src/events/busApi.js b/src/events/busApi.js index 7b01e055..4e51c98f 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -15,6 +15,7 @@ import { import { createEvent } from '../services/busApi'; import models from '../models'; import util from '../util'; +import createTaasJobsFromProject from '../events/projects/postTaasJobs'; /** * Map of project status and event name sent to bus api @@ -57,6 +58,14 @@ module.exports = (app, logger) => { app.on(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, ({ req, project }) => { logger.debug('receive PROJECT_DRAFT_CREATED event'); + // create taas jobs from project of type `talent-as-a-service` + if (project.type === 'talent-as-a-service') { + createTaasJobsFromProject(req, project, logger) + .catch((error) => { + logger.error(`Error while creating TaaS jobs: ${error}`); + }); + } + // send event to bus api createEvent(BUS_API_EVENT.PROJECT_CREATED, _.assign(project, { refCode: _.get(project, 'details.utm.code'), diff --git a/src/events/projects/index.js b/src/events/projects/index.js index a3db47c5..b3401098 100644 --- a/src/events/projects/index.js +++ b/src/events/projects/index.js @@ -5,7 +5,6 @@ import _ from 'lodash'; import Joi from 'joi'; import Promise from 'bluebird'; import config from 'config'; -import axios from 'axios'; import util from '../../util'; import models from '../../models'; import { createPhaseTopic } from '../projectPhases'; @@ -15,27 +14,6 @@ const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); const eClient = util.getElasticSearchClient(); -/** - * creates taas job - * @param {Object} data the job data - * @return {Object} the job created - */ -const createTaasJob = async (data) => { - const token = await util.getM2MToken(); - const headers = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }; - const res = await axios - .post(config.taasJobApiUrl, data, { headers }) - .catch((err) => { - const error = new Error(); - error.message = _.get(err, 'response.data.message', error.message); - throw error; - }); - return res.data; -}; - /** * Payload for deprecated BUS events like `connect.notification.project.updated`. */ @@ -186,42 +164,6 @@ async function projectCreatedKafkaHandler(app, topic, payload) { await Promise.all(topicPromises); app.logger.debug('Topics for phases are successfully created.'); } - try { - if (project.type === 'talent-as-a-service') { - const jobs = _.get(project, 'details.taasDefinition.taasJobs'); - if (!jobs || !jobs.length) { - app.logger.debug(`no jobs found in the project id: ${project.id}`); - return; - } - app.logger.debug(`${jobs.length} jobs found in the project id: ${project.id}`); - await Promise.all( - _.map( - jobs, - (job) => { - // make sure that skills would be unique in the list and only include ones with 'skillId' (actually they all suppose to be with skillId) - const skills = _.chain(job.skills).map('skillId').uniq().compact() - .value(); - return createTaasJob({ - projectId: project.id, - title: job.title, - description: job.description, - skills, - numPositions: Number(job.people), - resourceType: _.get(job, 'role.value', ''), - rateType: 'weekly', // hardcode for now - workload: _.get(job, 'workLoad.title', '').toLowerCase(), - }).then((createdJob) => { - app.logger.debug(`jobId: ${createdJob.id} job created with title "${createdJob.title}"`); - }).catch((err) => { - app.logger.error(`Unable to create job with title "${job.title}": ${err.message}`); - }); - }, - ), - ); - } - } catch (error) { - app.logger.error(`Error while creating TaaS jobs: ${error}`); - } } module.exports = { diff --git a/src/events/projects/postTaasJobs.js b/src/events/projects/postTaasJobs.js new file mode 100644 index 00000000..f2bedfff --- /dev/null +++ b/src/events/projects/postTaasJobs.js @@ -0,0 +1,73 @@ +/** + * Event handler for the creation of `talent-as-a-service` projects. + */ + +import _ from 'lodash'; +import config from 'config'; +import axios from 'axios'; + +/** + * Create taas job. + * + * @param {String} authHeader the authorization header + * @param {Object} data the job data + * @return {Object} the job created + */ +async function createTaasJob(authHeader, data) { + const headers = { + 'Content-Type': 'application/json', + Authorization: authHeader, + }; + const res = await axios + .post(config.taasJobApiUrl, data, { headers }) + .catch((err) => { + const error = new Error(); + error.message = _.get(err, 'response.data.message', error.message); + throw error; + }); + return res.data; +} + +/** + * Create taas jobs from project of type `talent-as-a-service` using the token from current user. + * + * @param {Object} req the request object + * @param {Object} project the project data + * @param {Object} logger the logger object + * @return {Object} the taas jobs created + */ +async function createTaasJobsFromProject(req, project, logger) { + const jobs = _.get(project, 'details.taasDefinition.taasJobs'); + if (!jobs || !jobs.length) { + logger.debug(`no jobs found in the project id: ${project.id}`); + return; + } + logger.debug(`${jobs.length} jobs found in the project id: ${project.id}`); + await Promise.all( + _.map( + jobs, + (job) => { + // make sure that skills would be unique in the list and only include ones with 'skillId' (actually they all suppose to be with skillId) + const skills = _.chain(job.skills).map('skillId').uniq().compact() + .value(); + return createTaasJob(req.headers.authorization, { + projectId: project.id, + title: job.title, + description: job.description, + duration: Number(job.duration), + skills, + numPositions: Number(job.people), + resourceType: _.get(job, 'role.value', ''), + rateType: 'weekly', // hardcode for now + workload: _.get(job, 'workLoad.title', '').toLowerCase(), + }).then((createdJob) => { + logger.debug(`jobId: ${createdJob.id} job created with title "${createdJob.title}"`); + }).catch((err) => { + logger.error(`Unable to create job with title "${job.title}": ${err.message}`); + }); + }, + ), + ); +} + +module.exports = createTaasJobsFromProject; diff --git a/src/permissions/constants.js b/src/permissions/constants.js index aa9f4ee9..41b7d3be 100644 --- a/src/permissions/constants.js +++ b/src/permissions/constants.js @@ -91,12 +91,20 @@ const SCOPES_PROJECTS_WRITE = [ M2M_SCOPES.PROJECTS.WRITE, ]; +/** + * M2M scopes to "read" available Billing Accounts for the project + */ +const SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS = [ + M2M_SCOPES.CONNECT_PROJECT_ADMIN, + M2M_SCOPES.READ_USER_BILLING_ACCOUNTS, +]; + /** * M2M scopes to "write" billingAccountId property */ -const SCOPES_PROJECTS_WRITE_BILLING_ACCOUNTS = [ +const SCOPES_PROJECTS_WRITE_PROJECTS_BILLING_ACCOUNTS = [ M2M_SCOPES.CONNECT_PROJECT_ADMIN, - M2M_SCOPES.PROJECTS.WRITE_BILLING_ACCOUNTS, + M2M_SCOPES.PROJECTS.WRITE_PROJECTS_BILLING_ACCOUNTS, ]; /** @@ -231,7 +239,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export USER_ROLE.MANAGER, USER_ROLE.TOPCODER_ADMIN, ], - scopes: SCOPES_PROJECTS_WRITE_BILLING_ACCOUNTS, + scopes: SCOPES_PROJECTS_WRITE_PROJECTS_BILLING_ACCOUNTS, }, DELETE_PROJECT: { @@ -252,6 +260,23 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export scopes: SCOPES_PROJECTS_WRITE, }, + /* + * Project Invite + */ + READ_AVL_PROJECT_BILLING_ACCOUNTS: { + meta: { + title: 'Read Available Project Billing Accounts', + group: 'Project Billing Accounts', + description: 'Who can view the Billing Accounts available for the project', + }, + projectRoles: [ + ...PROJECT_ROLES_MANAGEMENT, + PROJECT_MEMBER_ROLE.COPILOT, + ], + topcoderRoles: TOPCODER_ROLES_ADMINS, + scopes: SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS, + }, + /* * Project Member */ diff --git a/src/permissions/index.js b/src/permissions/index.js index a37fdb04..2e62dc7f 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -20,6 +20,10 @@ module.exports = () => { Authorizer.setPolicy('project.edit', generalPermission(PERMISSION.UPDATE_PROJECT)); Authorizer.setPolicy('project.delete', generalPermission(PERMISSION.DELETE_PROJECT)); + Authorizer.setPolicy('projectBillingAccounts.view', generalPermission([ + PERMISSION.READ_AVL_PROJECT_BILLING_ACCOUNTS, + ])); + Authorizer.setPolicy('projectMember.create', generalPermission([ PERMISSION.CREATE_PROJECT_MEMBER_OWN, PERMISSION.CREATE_PROJECT_MEMBER_NOT_OWN, diff --git a/src/routes/billingAccounts/list.js b/src/routes/billingAccounts/list.js new file mode 100644 index 00000000..77166d56 --- /dev/null +++ b/src/routes/billingAccounts/list.js @@ -0,0 +1,39 @@ +// import _ from 'lodash'; +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import SalesforceService from '../../services/salesforceService'; + +/** + * API to get project attachments. + * + */ + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectBillingAccounts.view'), + async (req, res, next) => { + // const projectId = _.parseInt(req.params.projectId); + const userId = req.authUser.userId; + try { + const { accessToken, instanceUrl } = await SalesforceService.authenticate(); + // eslint-disable-next-line + const sql = `SELECT Topcoder_Billing_Account__r.id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.Billing_Account_Name__c, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c from Topcoder_Billing_Account_Resource__c tbar where UserID__c='${userId}'`; + // and Topcoder_Billing_Account__r.TC_Connect_Project_ID__c='${projectId}' + req.log.debug(sql); + const billingAccounts = await SalesforceService.query(sql, accessToken, instanceUrl, req.log); + res.json(billingAccounts); + } catch (error) { + req.log.error(error); + next(error); + } + }, +]; diff --git a/src/routes/billingAccounts/list.spec.js b/src/routes/billingAccounts/list.spec.js new file mode 100644 index 00000000..bd76869c --- /dev/null +++ b/src/routes/billingAccounts/list.spec.js @@ -0,0 +1,182 @@ +/* eslint-disable no-unused-expressions */ +import chai from 'chai'; +import request from 'supertest'; +import sinon from 'sinon'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import SalesforceService from '../../services/salesforceService'; + +chai.should(); + +// demo data which might be returned by the `SalesforceService.query` +const billingAccountsData = [ + { + sfBillingAccountId: '123', + tcBillingAccountId: 123123, + name: 'Billing Account 1', + startDate: '2021-02-10T18:51:27Z', + endDate: '2021-03-10T18:51:27Z', + }, { + sfBillingAccountId: '456', + tcBillingAccountId: 456456, + name: 'Billing Account 2', + startDate: '2011-02-10T18:51:27Z', + endDate: '2011-03-10T18:51:27Z', + }, +]; + +describe('Project Billing Accounts list', () => { + let project1; + let salesforceAuthenticate; + let salesforceQuery; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => testUtil.clearES()) + .then(() => { + models.Project.create({ + type: 'generic', + directProjectId: 1, + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + project1 = p; + // create members + return models.ProjectMember.create({ + userId: testUtil.userIds.copilot, + projectId: project1.id, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => models.ProjectMember.create({ + userId: testUtil.userIds.member, + projectId: project1.id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + })); + }).then(() => { + salesforceAuthenticate = sinon.stub(SalesforceService, 'authenticate', () => Promise.resolve({ + accessToken: 'mock', + instanceUrl: 'mock_url', + })); + salesforceQuery = sinon.stub(SalesforceService, 'query', () => Promise.resolve(billingAccountsData)); + done(); + }); + }); + }); + + afterEach((done) => { + salesforceAuthenticate.restore(); + salesforceQuery.restore(); + done(); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('List /projects/{id}/billingAccounts', () => { + it('should return 403 for anonymous user', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/billingAccounts`) + .expect(403, done); + }); + + it('should return 403 for a customer user who is a member of the project', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/billingAccounts`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send() + .expect(403, done); + }); + + it('should return 403 for a topcoder user who is not a member of the project', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/billingAccounts`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilotManager}`, + }) + .send() + .expect(403, done); + }); + + it('should return all billing accounts for a topcoder user who is a member of the project', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/billingAccounts`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send() + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + resJson.should.have.length(2); + resJson.should.include(billingAccountsData[0]); + resJson.should.include(billingAccountsData[1]); + done(); + } + }); + }); + + it('should return all billing accounts to admin', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/billingAccounts`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send() + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + resJson.should.have.length(2); + resJson.should.have.length(2); + resJson.should.include(billingAccountsData[0]); + resJson.should.include(billingAccountsData[1]); + done(); + } + }); + }); + + it('should return all billing accounts using M2M token with "read:user-billing-accounts" scope', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/billingAccounts`) + .set({ + Authorization: `Bearer ${testUtil.m2m['read:user-billing-accounts']}`, + }) + .send() + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + resJson.should.have.length(2); + resJson.should.have.length(2); + resJson.should.include(billingAccountsData[0]); + resJson.should.include(billingAccountsData[1]); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/index.js b/src/routes/index.js index 29a180a0..5a28ee64 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -121,6 +121,9 @@ router.route('/v5/projects/:projectId(\\d+)/scopeChangeRequests/:requestId(\\d+) .patch(require('./scopeChangeRequests/update')); // .delete(require('./scopeChangeRequests/delete')); +router.route('/v5/projects/:projectId(\\d+)/billingAccounts') + .get(require('./billingAccounts/list')); + router.route('/v5/projects/:projectId(\\d+)/members') .get(require('./projectMembers/list')) .post(require('./projectMembers/create')); diff --git a/src/routes/projectMembers/create.js b/src/routes/projectMembers/create.js index 1d15991a..fd35fd26 100644 --- a/src/routes/projectMembers/create.js +++ b/src/routes/projectMembers/create.js @@ -80,6 +80,13 @@ module.exports = [ newMember = await util.addUserToProject(req, member, transaction); }); + try { + const fields = req.query.fields ? req.query.fields.split(',') : null; + [newMember] = await util.getObjectsWithMemberDetails([newMember], fields, req); + } catch (err) { + req.log.error('Cannot get user details for member.'); + req.log.debug('Error during getting user details for member.', err); + } return res.status(201).json(newMember); } catch (err) { return next(err); diff --git a/src/services/salesforceService.js b/src/services/salesforceService.js new file mode 100644 index 00000000..09dc66e2 --- /dev/null +++ b/src/services/salesforceService.js @@ -0,0 +1,82 @@ +/** + * Represents the Salesforce service + */ +import _ from 'lodash'; +import config from 'config'; +import jwt from 'jsonwebtoken'; +import util from '../util'; + +const axios = require('axios'); + +const loginBaseUrl = config.salesforce.CLIENT_AUDIENCE || 'https://login.salesforce.com'; +// we are using dummy private key to fail safe when key is not provided in env +let privateKey = config.salesforce.CLIENT_KEY || 'privateKey'; +privateKey = privateKey.replace(/\\n/g, '\n'); + +const urlEncodeForm = k => + Object.keys(k).reduce((a, b) => `${a}&${b}=${encodeURIComponent(k[b])}`, ''); + +/** + * Helper class to abstract salesforce API calls + */ +class SalesforceService { + /** + * Authenticate to Salesforce with pre-configured credentials + * @returns {{accessToken: String, instanceUrl: String}} the result + */ + static authenticate() { + const jwtToken = jwt.sign({}, privateKey, { + expiresIn: '1h', // any expiration + issuer: config.salesforce.CLIENT_ID, + audience: config.salesforce.CLIENT_AUDIENCE, + subject: config.salesforce.SUBJECT, + algorithm: 'RS256', + }); + return axios({ + method: 'post', + url: `${loginBaseUrl}/services/oauth2/token`, + data: urlEncodeForm({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtToken, + }), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }).then(res => ({ + accessToken: res.data.access_token, + instanceUrl: res.data.instance_url, + })); + } + + /** + * Run the query statement + * @param {String} sql the Saleforce sql statement + * @param {String} accessToken the access token + * @param {String} instanceUrl the salesforce instance url + * @param {Object} logger logger to be used for logging + * @returns {{totalSize: Number, done: Boolean, records: Array}} the result + */ + static query(sql, accessToken, instanceUrl, logger) { + return axios({ + url: `${instanceUrl}/services/data/v37.0/query?q=${sql}`, + method: 'get', + headers: { authorization: `Bearer ${accessToken}` }, + }).then((res) => { + if (logger) { + logger.debug(_.get(res, 'data.records', [])); + } + const billingAccounts = _.get(res, 'data.records', []).map(o => ({ + sfBillingAccountId: _.get(o, 'Topcoder_Billing_Account__r.Id'), + tcBillingAccountId: util.parseIntStrictly( + _.get(o, 'Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c'), + 10, + null, // fallback to null if cannot parse + ), + name: _.get(o, 'Topcoder_Billing_Account__r.Billing_Account_Name__c'), + startDate: _.get(o, 'Topcoder_Billing_Account__r.Start_Date__c'), + endDate: _.get(o, 'Topcoder_Billing_Account__r.End_Date__c'), + })); + return billingAccounts; + }); + } +} + +export default SalesforceService; diff --git a/src/tests/util.js b/src/tests/util.js index a98e7b5c..85a4bc64 100644 --- a/src/tests/util.js +++ b/src/tests/util.js @@ -39,6 +39,8 @@ export default { }, m2m: { [M2M_SCOPES.CONNECT_PROJECT_ADMIN]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOmNvbm5lY3RfcHJvamVjdCIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.q34b2IC1pw3ksl5RtnSEW5_HGwN0asx2MD3LV9-Wffg', + // TODO update token to have correct scope, as of now it is copied from CONNECT_PROJECT_ADMIN + [M2M_SCOPES.PROJECTS.READ_USER_BILLING_ACCOUNTS]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOmNvbm5lY3RfcHJvamVjdCIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.q34b2IC1pw3ksl5RtnSEW5_HGwN0asx2MD3LV9-Wffg', [M2M_SCOPES.PROJECTS.ALL]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOnByb2plY3RzIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.ixFXMCsBmIN9mQ9Z3s-Apkg20A3d86Pm9RouL7bZMV4', [M2M_SCOPES.PROJECTS.READ]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoicmVhZDpwcm9qZWN0cyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.IpYgfbem-eR6tGjBoxQBPDw6YIulBTZLBn48NuyJT_g', [M2M_SCOPES.PROJECTS.WRITE]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoid3JpdGU6cHJvamVjdHMiLCJndHkiOiJjbGllbnQtY3JlZGVudGlhbHMifQ.cAMbmnSKXB8Xl4s4Nlo1LduPySBcvKz2Ygilq5b0OD0', diff --git a/src/util.js b/src/util.js index 1cdd8cfd..a5ab739a 100644 --- a/src/util.js +++ b/src/util.js @@ -1473,6 +1473,34 @@ const projectServiceUtils = { }); }, + /** + * Parse integer value inside string or return fallback value. + * Unlike original parseInt, this method parses the whole string + * and fails if there are non-integer characters inside the string. + * + * @param {String} str number to parse + * @param {Number} radix radix of the number to parse + * @param {Any} fallbackValue value to return if we cannot parse successfully + * + * @returns {Number} parsed number + */ + parseIntStrictly: (str, radix, fallbackValue) => { + const int = parseInt(str, radix); + + if (_.isNaN(int)) { + return fallbackValue; + } + + // if we parsed only the part of value and it's not the same as intial value + // example: "12x" => 12 which is not the same as initial value "12x", which means + // we cannot parse the full value sucessfully and treat it like we cannot parse at all + if (int.toString() !== str) { + return fallbackValue; + } + + return int; + }, + }; _.assignIn(util, projectServiceUtils); diff --git a/src/util.spec.js b/src/util.spec.js index 0fe574ed..e1a9ba8d 100644 --- a/src/util.spec.js +++ b/src/util.spec.js @@ -1,12 +1,38 @@ /** * Tests for util.js */ -import chai from 'chai'; +import chai, { expect } from 'chai'; import util from './util'; chai.should(); describe('Util method', () => { + describe('parseIntStrictly', () => { + it('should parse a good integer value sucessfully', () => { + util.parseIntStrictly('1234567890', 10, null).should.equal(1234567890); + }); + + it('should return fallback value if the initial value is a float number', () => { + expect(util.parseIntStrictly('1.1', 10, null)).be.equal(null); + }); + + it('should return fallback value if string can be parsed partially only', () => { + expect(util.parseIntStrictly('123XXX', 10, null)).be.equal(null); + }); + + it('should return fallback value if the initial value is `null`', () => { + util.parseIntStrictly(null, 10, 0).should.equal(0); + }); + + it('should return fallback value if the initial value is `undefined`', () => { + expect(util.parseIntStrictly(undefined, 10, null)).be.equal(null); + }); + + it('should return fallback value if the initial value is `""` (emtpy string)', () => { + expect(util.parseIntStrictly('', 10, null)).be.equal(null); + }); + }); + describe('maskEmail', () => { it('should return the original value if the email is non-string', () => { chai.should().not.exist(util.maskEmail(null));