From b3e2de9f57206ebb1f390321dba54987704e5010 Mon Sep 17 00:00:00 2001 From: Senad Uka Date: Sat, 31 Aug 2019 06:11:15 +0200 Subject: [PATCH] Bugfix for fees / Oauth 2 implementation --- client/src/scenes/IncidentsReport/index.js | 7 +- constants/constants.js | 1 + cronServices/oauth.js | 8 ++ environment.env | 5 +- helpers/api.js | 21 +++- helpers/oauthApi.js | 15 +++ migrations/20190830134013-add-tokens-table.js | 27 +++++ models/accessToken.js | 12 ++ package-lock.json | 23 +++- package.json | 4 +- server.js | 2 +- services/integration/invoiceIntegration.js | 8 +- services/officeRnD/fees.js | 6 +- services/officeRnD/memberships.js | 20 ++-- services/officeRnD/oauth.js | 109 ++++++++++++++++++ 15 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 cronServices/oauth.js create mode 100644 helpers/oauthApi.js create mode 100644 migrations/20190830134013-add-tokens-table.js create mode 100644 models/accessToken.js create mode 100644 services/officeRnD/oauth.js diff --git a/client/src/scenes/IncidentsReport/index.js b/client/src/scenes/IncidentsReport/index.js index 2a2ebc6..27dc370 100644 --- a/client/src/scenes/IncidentsReport/index.js +++ b/client/src/scenes/IncidentsReport/index.js @@ -7,7 +7,7 @@ import MonthSelector from '../../components/MonthSelector'; import MemberIncidentsTables from '../../components/MemberIncidentsTables'; import GenerateFeesInORDButton from '../../components/GenerateFeesInORDButton'; -import { fetchIncidents, addFeesToOrd } from '../../store/actions'; +import { fetchIncidents } from '../../store/actions'; class IncidentsReport extends Component { state = {dateRange: null}; @@ -31,7 +31,6 @@ class IncidentsReport extends Component { membersMap[incident.memberId] = true; }); } - const memberIds = Object.keys(membersMap) || []; return ( @@ -41,7 +40,6 @@ class IncidentsReport extends Component {
@@ -61,8 +59,7 @@ const mapStateToProps = (state) => ({ }); const mapDispatchToProps = (dispatch) => ({ - fetchIncidents: (dateRange) => fetchIncidents(dispatch, dateRange), - addFeesToOrd: (dateRange, memberIds) => addFeesToOrd(dispatch, dateRange, memberIds), + fetchIncidents: (dateRange) => fetchIncidents(dispatch, dateRange) }); export default connect(mapStateToProps, mapDispatchToProps)(IncidentsReport); diff --git a/constants/constants.js b/constants/constants.js index 84f5a32..7306753 100644 --- a/constants/constants.js +++ b/constants/constants.js @@ -62,6 +62,7 @@ const officeRnDAPIErrors = { FAILED_TO_DELETE_FEES: 'Failed to delete fees in ORD', FAILED_TO_ADD_FEES: 'Failed to add fees in ORD', MEMBERSHIPS_ARE_NOT_LOADED_CORRECTLY: 'Memberships are not loaded correctly', + OAUTH_FAILED: 'Failed to fetch new OAUTH token', }; const integrationServiceErrors = { PROCESSING_TRY_AGAIN: 'Incident calculations are in progress. Please try again in a few minutes', diff --git a/cronServices/oauth.js b/cronServices/oauth.js new file mode 100644 index 0000000..81a482a --- /dev/null +++ b/cronServices/oauth.js @@ -0,0 +1,8 @@ +'use strict'; + +const { refreshOauthToken } = require('../services/officeRnD/oauth'); + +refreshOauthToken().then(() => process.exit()).catch((error) => { + console.log(error); + process.exit(); +}); diff --git a/environment.env b/environment.env index a3e73a1..bbe8c64 100644 --- a/environment.env +++ b/environment.env @@ -1,7 +1,6 @@ BASIC_AUTH_USERNAME=username BASIC_AUTH_PASSWORD=password -OFFICE_RnD_TOKEN=token for Office RnD API requests MAX_BACK_TO_BACK_DIFFERENCE=Time in minutes EARLIEST_UNLOCK=Time in minutes @@ -32,6 +31,10 @@ DISCOUNT_LEVEL_2_HOURS=Hours requred to apply DISCOUNT_LEVEL_2_PERCENTAGE discou DISCOUNT_LEVEL_2_PERCENTAGE=Discount to apply in percentage, if DISCOUNT_LEVEL_2_HOURS of billable hours is booked DISCOUNT_PLANS=Plan names for which discount is available. Comma-separated +ORD_OAUTH_CLIENT_ID=Client ID for this app, created in ORD; Used to obtain OAUTH token +ORD_OAUTH_CLIENT_SECRET=Client secret for this app; Used to obtain OAUTH token +ORD_OAUTH_URL=https://identity.officernd.com/oauth/token + #More about pool option : http://docs.sequelizejs.com/class/lib/sequelize.js~Sequelize.html DB_POOL_MAX_CONNECTIONS=Maximum number of connection in pool (ex. 18) DB_POOL_ACQUIRE=The maximum time, in milliseconds, that pool will try to get connection before throwing error (ex. 120000) diff --git a/helpers/api.js b/helpers/api.js index 441105d..d9c69d5 100644 --- a/helpers/api.js +++ b/helpers/api.js @@ -1,10 +1,27 @@ const axios = require('axios'); -const officeRnDToken = process.env.OFFICE_RnD_TOKEN; +const { getToken } = require('../services/officeRnD/oauth'); const API = axios.create({ baseURL: 'https://app.officernd.com/api/v1/organizations/sima-space-test-environment', - headers: {'Authorization': `Bearer ${officeRnDToken}`} + headers: {'Authorization': `Bearer TOKEN`} +}); + +// request interceptor +API.interceptors.request.use((config) => { + return new Promise((resolve, reject) => { + getToken(true) + .then((token) => { + config.headers.Authorization = `Bearer ${token}`; + resolve(config); + }) + .catch((error) => { + reject(error); + }); + }); +}, (error) => { + // Do something with request error + return Promise.reject(error); }); module.exports = { diff --git a/helpers/oauthApi.js b/helpers/oauthApi.js new file mode 100644 index 0000000..96b8ed9 --- /dev/null +++ b/helpers/oauthApi.js @@ -0,0 +1,15 @@ +'use strict'; + +require('dotenv').config(); +const axios = require('axios'); + +const OAUTHUrl = process.env.ORD_OAUTH_URL; + +const OAuthAPI = axios.create({ + baseURL: OAUTHUrl, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, +}); + +module.exports = { + OAuthAPI, +}; diff --git a/migrations/20190830134013-add-tokens-table.js b/migrations/20190830134013-add-tokens-table.js new file mode 100644 index 0000000..311498d --- /dev/null +++ b/migrations/20190830134013-add-tokens-table.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('accessTokens', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + token: Sequelize.TEXT, + validUntil: Sequelize.DATE, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('accessTokens'); + } +}; diff --git a/models/accessToken.js b/models/accessToken.js new file mode 100644 index 0000000..90f4269 --- /dev/null +++ b/models/accessToken.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const processing = sequelize.define('accessToken', { + token: DataTypes.TEXT, + validUntil: DataTypes.DATE, + }, {}); + processing.associate = function(models) { + // associations can be defined here + }; + return processing; +}; diff --git a/package-lock.json b/package-lock.json index bae1c22..f6114cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -684,8 +684,7 @@ "decode-uri-component": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" }, "deep-extend": { "version": "0.6.0", @@ -3029,6 +3028,16 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, + "query-string": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.8.2.tgz", + "integrity": "sha512-J3Qi8XZJXh93t2FiKyd/7Ec6GNifsjKXUsVFkSBj/kjLsDylWhnCz4NT1bkPcKotttPW+QbKGqqPH8OoI2pdqw==", + "requires": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3519,6 +3528,11 @@ "through": "2" } }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -3562,6 +3576,11 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", diff --git a/package.json b/package.json index 8dd67db..f87b7e6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "start-client": "cd client && yarn start", "start": "node server.js", "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix client && npm run build --prefix client", - "check-booking-changes": "node ./cronServices/checkBookingChanges.js" + "check-booking-changes": "node ./cronServices/checkBookingChanges.js", + "refresh-oauth": "node ./cronServices/oauth.js" }, "engines": { "node": "11.12.x" @@ -32,6 +33,7 @@ "moment": "^2.24.0", "moment-timezone": "^0.5.25", "pg": "^7.11.0", + "query-string": "^6.8.2", "sequelize": "^5.8.6", "sequelize-cli": "^5.4.0" }, diff --git a/server.js b/server.js index ff8a27d..7dfd5b9 100644 --- a/server.js +++ b/server.js @@ -29,7 +29,7 @@ app.use(express.static(path.join(__dirname, 'client/build'))); //production mode if(process.env.NODE_ENV === 'production') { app.get('*', (req, res) => { - res.sendfile(path.join(__dirname = 'client/build/index.html')); + res.sendFile(path.join(__dirname = 'client/build/index.html')); }); } diff --git a/services/integration/invoiceIntegration.js b/services/integration/invoiceIntegration.js index 077c9c2..67b9b81 100644 --- a/services/integration/invoiceIntegration.js +++ b/services/integration/invoiceIntegration.js @@ -247,6 +247,10 @@ const createNegativeFeeForDiscount = (memberData, dateRange) => { } }); + if (membershipFeeForDiscount === 0){ + return null; //This member's plan is not eligible for a discount + } + const totalChargeFee = membershipFeeForDiscount + totalBookingChargedFee; let discount = 0; @@ -261,7 +265,7 @@ const createNegativeFeeForDiscount = (memberData, dateRange) => { const discountRate = discountPercentage / 100; discount = totalChargeFee * discountRate; }else{ - return null; + return null; //Not enough hours to earn a discount } const formattedName = `[Discount] Total booked : ${totalBookedHours.toFixed(2)} hrs, Total charged : ${totalChargedHours.toFixed(2)} hrs, Discount : ${discountPercentage} %`; @@ -317,7 +321,7 @@ const getMembersFeesForDateRange = (dateRange, memberIds) => { membersMap[member.memberId] = { member, bookingData: Object.assign({}, oneMemberObject), - membershipFees: membershipsMap[member.memberId], + membershipFees: membershipsMap[member.memberId] || [], }; }); diff --git a/services/officeRnD/fees.js b/services/officeRnD/fees.js index 287baf1..a55a109 100644 --- a/services/officeRnD/fees.js +++ b/services/officeRnD/fees.js @@ -33,6 +33,8 @@ const deleteFeesFromORD = (dateRange, memberIds) => { memberIdsMap[memberId] = true; }); + const filterByMemberIds = memberIds.length > 0; + const feeIdsToRemove = []; fetchedFees.forEach((fee) => { @@ -42,7 +44,9 @@ const deleteFeesFromORD = (dateRange, memberIds) => { const isDateInDateRange = startDate.isSameOrBefore(date) && endDate.isSameOrAfter(date); const manuallyAddedFee = manualFeeNames.indexOf(name) !== -1; - if (memberIdsMap[member] && isDateInDateRange && (status === UNPAID_FEE_STATUS) && !manuallyAddedFee) { + const memberFeesShouldBeDeleted = filterByMemberIds ? memberIdsMap[member] : true; + + if (memberFeesShouldBeDeleted && isDateInDateRange && (status === UNPAID_FEE_STATUS) && !manuallyAddedFee) { feeIdsToRemove.push(feeId); } }); diff --git a/services/officeRnD/memberships.js b/services/officeRnD/memberships.js index 328cdf7..7049179 100644 --- a/services/officeRnD/memberships.js +++ b/services/officeRnD/memberships.js @@ -10,14 +10,18 @@ const fetchAllMembershipsForMemberIds = (memberIds) => { const memberships = result.data || []; if (Array.isArray(memberIds)){ - const filteredMemberships = []; - memberships.forEach((membership) => { - const { member } = membership; - if (memberIds.indexOf(member) !== -1){ - filteredMemberships.push(membership); - } - }); - resolve(filteredMemberships); + if (memberIds.length > 0){ + const filteredMemberships = []; + memberships.forEach((membership) => { + const { member } = membership; + if (memberIds.indexOf(member) !== -1){ + filteredMemberships.push(membership); + } + }); + resolve(filteredMemberships); + }else{ + resolve(memberships); + } }else{ reject(integrationServiceErrors.EXPECTED_MEMBER_IDS_ARRAY); } diff --git a/services/officeRnD/oauth.js b/services/officeRnD/oauth.js new file mode 100644 index 0000000..778d45a --- /dev/null +++ b/services/officeRnD/oauth.js @@ -0,0 +1,109 @@ +'use strict'; +require('dotenv').config(); +const moment = require('moment'); +const queryString = require('query-string'); + +const { OAuthAPI } = require('../../helpers/oauthApi'); +const db = require('../../models/index'); +const { officeRnDAPIErrors } = require('../../constants/constants'); + +const updateToken = (token, validUntil) => { + return new Promise((resolve, reject) => { + const values = {token, validUntil}; + db.accessToken.update(values, {where:{}}) + .then(([numberOfUpdatedRows]) => { + if (numberOfUpdatedRows > 0){ + resolve(true); + }else{ + db.accessToken.bulkCreate([{token, validUntil}]) + .then(() => { + resolve(false); + }) + .catch((error) => reject(error)); + } + }) + .catch((error) => reject(error)); + }); +}; + +const getToken = (returnTokenOnly = false) => { + return new Promise((resolve, reject) => { + db.accessToken.findAll() + .then((results) => { + if (results && results.length > 0){ + const token = results[0].getDataValue('token'); + const validUntil = results[0].getDataValue('validUntil'); + const validUntilMoment = moment(validUntil); + if (validUntilMoment.isBefore(moment())){ + refreshOauthToken() + .then((oauthResult) => { + if (returnTokenOnly){ + resolve(oauthResult.token); + }else{ + resolve(oauthResult); + } + }) + .catch((error) => reject(error)); + }else{ + if (returnTokenOnly){ + resolve(token); + }else { + resolve({token, validUntil}); + } + } + }else{ + refreshOauthToken() + .then((oauthResult) => { + if (returnTokenOnly){ + resolve(oauthResult.token); + }else{ + resolve(oauthResult); + } + }) + .catch((error) => reject(error)); + } + }) + .catch((error) => reject(error)); + }); +}; + +const refreshOauthToken = () => { + return new Promise((resolve, reject) => { + const clientID = process.env.ORD_OAUTH_CLIENT_ID; + const clientSecret = process.env.ORD_OAUTH_CLIENT_SECRET; + + const OAUTHRequestBody = { + client_id: clientID, + client_secret: clientSecret, + grant_type: 'client_credentials', + scope: 'officernd.api.read officernd.api.write', + }; + + OAuthAPI.post('/', queryString.stringify(OAUTHRequestBody)) + .then((oauthResponse) => { + const responseData = oauthResponse && oauthResponse.data ? oauthResponse.data : null; + if (responseData){ + const { access_token: accessToken, expires_in: expiresIn } = responseData; + if (accessToken && expiresIn){ + const validUntil = moment().add(expiresIn, 'seconds').toISOString(); + updateToken(accessToken, validUntil) + .then(() => resolve({token: accessToken, validUntil})) + .catch((error) => reject(error)); + }else{ + reject(officeRnDAPIErrors.OAUTH_FAILED); + } + }else{ + reject(officeRnDAPIErrors.OAUTH_FAILED); + } + }) + .catch((error) => { + console.log('Error obtaining OAUTH token : ', error); + reject(); + }); + }); +}; + +module.exports = { + getToken, + refreshOauthToken, +};