diff --git a/client/src/store/actions/index.js b/client/src/store/actions/index.js index 4415670..bc138de 100644 --- a/client/src/store/actions/index.js +++ b/client/src/store/actions/index.js @@ -1,2 +1,2 @@ export * from './doorLockActions'; -export * from './officeRnDActions'; +export * from './integrationActions'; diff --git a/client/src/store/actions/officeRnDActions.js b/client/src/store/actions/integrationActions.js similarity index 92% rename from client/src/store/actions/officeRnDActions.js rename to client/src/store/actions/integrationActions.js index f858967..b61b58b 100644 --- a/client/src/store/actions/officeRnDActions.js +++ b/client/src/store/actions/integrationActions.js @@ -11,7 +11,7 @@ import API from '../../utilities/api'; export const fetchMappings = (dispatch) => { dispatch({type: FETCH_MAPPINGS_PENDING}); - API.get('doorLock/mappings') + API.get('integration/mappings') .then(response => { dispatch({type: FETCH_MAPPINGS_SUCCESS, payload: response.data}); }) @@ -22,7 +22,7 @@ export const fetchMappings = (dispatch) => { export const addNewMapping = (dispatch, mapping) => { dispatch({type: ADD_NEW_MAPPING_PENDING}); - API.post('doorLock/mappings', { + API.post('integration/mappings', { mapping }) .then(response => { diff --git a/config/config.json b/config/config.json index 9d696a2..bb7a042 100644 --- a/config/config.json +++ b/config/config.json @@ -3,8 +3,9 @@ "username": "docker", "password": "docker", "database": "CrmIntegration", - "port": "5432", - "dialect": "postgres" + "port": "5431", + "dialect": "postgres", + "logging": false }, "test": { "use_env_variable": "DATABASE_URL" diff --git a/constants/constants.js b/constants/constants.js index 7344665..1650930 100644 --- a/constants/constants.js +++ b/constants/constants.js @@ -4,22 +4,64 @@ const DISABLE_PASSAGE_MODE = 'Disable Passage Mode by Group 2'; const VALID_CSV_HEADERS = ['Date', 'Time', 'User No', 'Name', 'Event']; + const doorLockEvents = { USER_LOCKED: 'locked', USER_UNLOCKED: 'unlocked', }; -const doorChargeTypes = { - LEFT_UNLOCKED: 'unlocked', - UNSCHEDULED_USE: 'unscheduled' +const unlockedIncidentLevelsPrices = { + UNLOCKED_0: { + id: 0, + title: 'UNLOCKED_0', + price: parseInt(process.env.UNLOCK_0) || 0 + }, + UNLOCKED_1: { + id: 1, + title: 'UNLOCKED_1', + price: parseInt(process.env.UNLOCK_1) || 10 + }, + UNLOCKED_2: { + id: 2, + title: 'UNLOCKED_2', + price: parseInt(process.env.UNLOCK_2) || 20 + }, + UNLOCKED_3: { + id: 3, + title: 'UNLOCKED_3', + price: parseInt(process.env.UNLOCK_3) || 30 + }, + UNLOCKED_4: { + id: 4, + title: 'UNLOCKED_4', + price: parseInt(process.env.UNLOCK_4) || 40 + }, + UNLOCKED_5: { + id: 5, + title: 'UNLOCKED_5', + price: parseInt(process.env.UNLOCK_5) || 50 + } }; const csvParserErrors = { INVALID_HEADERS: 'Invalid headers', INVALID_ENTRY_EXPECTED_USER: 'Invalid entry type. Expected user entry type', INVALID_ENTRY_EXPECTED_PASSAGE_MODE: 'Invalid entry type. Expected enable/disable passage mode following user entry', UNKNOWN_MEMBER: 'Member is not registered in OfficeRnD system', + GENERIC_ERROR: 'There was error while parsing uploaded file(s)', }; const officeRnDAPIErrors = { FAILED_TO_FETCH_MEMBERS: 'Failed to fetch members', + FAILED_TO_FETCH_BOOKINGS: 'Failed to fetch booking reservations' +}; +const integrationServiceErrors = { + FAILED_TO_SAVE_BOOKINGS: 'Failed to save booking reservations', + FAILED_TO_SAVE_DOOR_LOCK_ENTRIES: 'Failed to save door lock entries', + FAILED_TO_SAVE_DATA_GENERIC: 'Failed to save data', +}; + +const incidentType = { + NOT_AN_INCIDENT: 1, + UNLOCKED_INCIDENT: 2, + UNSCHEDULED_INCIDENT: 3, }; module.exports = { @@ -30,5 +72,7 @@ module.exports = { csvParserErrors, officeRnDAPIErrors, doorLockEvents, - doorChargeTypes, + unlockedIncidentLevelsPrices, + integrationServiceErrors, + incidentType, }; diff --git a/controllers/doorLock.js b/controllers/doorLock.js index 7520b3c..68db008 100644 --- a/controllers/doorLock.js +++ b/controllers/doorLock.js @@ -1,9 +1,9 @@ 'use strict'; -const { getMappingsFromDatabase, fetchOffices, fetchResources, saveNewMappingToDatabase } = require('../services/officeRnD/resources'); -const { parseDoorLockDataFile, writeDoorLockEvent } = require('../services/doorLock'); +const { parseDoorLockDataFile, writeDoorLockEvent } = require('../services/doorLock/doorLock'); const { fetchAllBookings, writeBookingReservation } = require('../services/officeRnD/bookings'); -const { officeRnDAPIErrors } = require('../constants/constants'); +const { calculateDoorLockCharges } = require('../services/integration/doorLockCharges'); +const { integrationServiceErrors } = require('../constants/constants'); const IncomingForm = require('formidable').IncomingForm; @@ -30,64 +30,42 @@ const uploadDoorLockData = (req, res) => { unknownMembers.push(...parserResult.unknownMembers); }); - res.json({ - parsedData, - parserErrors, - unknownMembers - }); + const asyncJobs = []; fetchAllBookings() .then((bookingEntries) => { - bookingEntries.forEach((bookingEntry) => writeBookingReservation(bookingEntry)); + bookingEntries.forEach((bookingEntry) => asyncJobs.push(writeBookingReservation(bookingEntry))); }) .catch((error) => { - console.log('===> ERROR'); - console.log(error); + res.status(500).send(error); + return; }); - parsedData.forEach((entry) => { - writeDoorLockEvent(entry); - }); + parsedData.forEach((entry) => asyncJobs.push(writeDoorLockEvent(entry))); + + Promise.all(asyncJobs) + .then(() => { + res.json({ + parsedData, + parserErrors, + unknownMembers + }); + + calculateDoorLockCharges(); + }) + .catch((error) => { + console.log(`${integrationServiceErrors.FAILED_TO_SAVE_BOOKINGS} or ${integrationServiceErrors.FAILED_TO_SAVE_DOOR_LOCK_ENTRIES}`) + console.log(error); + res.status(500).send(integrationServiceErrors.FAILED_TO_SAVE_DATA_GENERIC); + }); }) .catch((error) => { - res.status(500).send(officeRnDAPIErrors.FAILED_TO_FETCH_MEMBERS); + res.status(500).send(error); }); }); - form.parse(req); }; -const getKnownOfficeResourceMappings = (req, res) => { - const dataToFetch = [getMappingsFromDatabase(), fetchOffices(), fetchResources() ]; - - Promise.all(dataToFetch) - .then(result => { - res.send({ - existingMappings: result[0], - offices: result[1], - resources: result[2], - }); - }) - .catch(error => { - res.status(500).send(); - }); -}; - -const addNewMapping = (req, res) => { - const newMapping = req.body && req.body.mapping ? req.body.mapping : null; - if (newMapping && newMapping.officeSlug && newMapping.resourceSlug && newMapping.officeId && newMapping.resourceId){ - saveNewMappingToDatabase(newMapping) - .then(result => { - res.send(newMapping); - }) - .catch(error => { - res.status(500).send(error); - }); - } -}; - module.exports = { uploadDoorLockData, - getKnownOfficeResourceMappings, - addNewMapping, }; diff --git a/controllers/integration.js b/controllers/integration.js new file mode 100644 index 0000000..6d56b8c --- /dev/null +++ b/controllers/integration.js @@ -0,0 +1,37 @@ +'use strict'; + +const { getMappingsFromDatabase, fetchOffices, fetchResources, saveNewMappingToDatabase } = require('../services/officeRnD/resources'); + +const getKnownOfficeResourceMappings = (req, res) => { + const dataToFetch = [getMappingsFromDatabase(), fetchOffices(), fetchResources() ]; + + Promise.all(dataToFetch) + .then(result => { + res.send({ + existingMappings: result[0], + offices: result[1], + resources: result[2], + }); + }) + .catch(error => { + res.status(500).send(); + }); +}; + +const addNewMapping = (req, res) => { + const newMapping = req.body && req.body.mapping ? req.body.mapping : null; + if (newMapping && newMapping.officeSlug && newMapping.resourceSlug && newMapping.officeId && newMapping.resourceId){ + saveNewMappingToDatabase(newMapping) + .then(() => { + res.send(newMapping); + }) + .catch(error => { + res.status(500).send(error); + }); + } +}; + +module.exports = { + getKnownOfficeResourceMappings, + addNewMapping, +}; diff --git a/environment.env b/environment.env index b602a2b..4283c09 100644 --- a/environment.env +++ b/environment.env @@ -1,2 +1,18 @@ 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=2 + +UNSCHEDULED_USE_TIME_RESOLUTION=Time in minutes +UNSCHEDULED_USE_CHARGE_FEE=Charge fee + +UNLOCK_0=Price for unlocked door, first month +UNLOCK_1=Price for unlocked door, second month +UNLOCK_2=Price for unlocked door, third month +UNLOCK_3=Price for unlocked door, fourth month +UNLOCK_4=Price for unlocked door, fifth month +UNLOCK_5=Price for unlocked door, sixth month + +UNLOCK_STREAK_REPAIR_AFTER=Number of months without incidents to reset user incident level diff --git a/migrations/20190611072735-add-office-column-in-booking-reservations-table.js b/migrations/20190611072735-add-office-column-in-booking-reservations-table.js new file mode 100644 index 0000000..efbf82d --- /dev/null +++ b/migrations/20190611072735-add-office-column-in-booking-reservations-table.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('bookingReservations', 'officeId', { + type: Sequelize.TEXT, + after: 'memberId', + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('bookingReservations', 'officeId'); + } +}; diff --git a/migrations/20190612123751-rename-door-lock-incidents-table.js b/migrations/20190612123751-rename-door-lock-incidents-table.js new file mode 100644 index 0000000..f71ac1c --- /dev/null +++ b/migrations/20190612123751-rename-door-lock-incidents-table.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.renameTable('doorLockIncidents', 'unscheduledIncidents'); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.renameTable('unscheduledIncidents', 'doorLockIncidents'); + } +}; diff --git a/migrations/20190612123950-alter-unscheduledIncidents-table.js b/migrations/20190612123950-alter-unscheduledIncidents-table.js new file mode 100644 index 0000000..bcd18ea --- /dev/null +++ b/migrations/20190612123950-alter-unscheduledIncidents-table.js @@ -0,0 +1,35 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.removeColumn('unscheduledIncidents', 'chargeType'), + queryInterface.addColumn('unscheduledIncidents', 'chargePrice', { + type: Sequelize.FLOAT, + after: 'doorLockEventTimestamp' + }), + queryInterface.addColumn('unscheduledIncidents', 'timeIntervalsToCharge', { + type: Sequelize.INTEGER, + after: 'chargePrice' + }), + queryInterface.renameColumn('unscheduledIncidents', 'chargeFee', 'totalChargeFee') + ]); + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.renameColumn('unscheduledIncidents', 'totalChargeFee', 'chargeFee'), + queryInterface.removeColumn('unscheduledIncidents', 'timeIntervalsToCharge'), + queryInterface.removeColumn('unscheduledIncidents', 'chargePrice'), + queryInterface.addColumn('unscheduledIncidents', 'chargeType', { + type: Sequelize.ENUM, + values: ['unlocked', 'unscheduled'], + after: 'doorLockEventTimestamp' + }), + ]); + }); + } +}; diff --git a/migrations/20190612125150-add-unlocked-incidents-table.js b/migrations/20190612125150-add-unlocked-incidents-table.js new file mode 100644 index 0000000..721057b --- /dev/null +++ b/migrations/20190612125150-add-unlocked-incidents-table.js @@ -0,0 +1,35 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('unlockedIncidents', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + reservationId: Sequelize.TEXT, + memberId: Sequelize.TEXT, + resourceId: Sequelize.TEXT, + bookingStart: Sequelize.DATE, + bookingEnd: Sequelize.DATE, + incidentLevel: { + type: Sequelize.ENUM, + values: ['UNLOCKED_0', 'UNLOCKED_1', 'UNLOCKED_2', 'UNLOCKED_3', 'UNLOCKED_4', 'UNLOCKED_5'] + }, + incidentLevelPrice: Sequelize.FLOAT, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('doorLockIncidents'); + } +}; diff --git a/models/bookingReservation.js b/models/bookingReservation.js index 9ced517..d977de3 100644 --- a/models/bookingReservation.js +++ b/models/bookingReservation.js @@ -4,6 +4,7 @@ module.exports = (sequelize, DataTypes) => { const bookingReservation = sequelize.define('bookingReservation', { reservationId: DataTypes.TEXT, memberId: DataTypes.TEXT, + officeId: DataTypes.TEXT, resourceId: DataTypes.TEXT, start: DataTypes.DATE, end: DataTypes.DATE, diff --git a/models/unlockedIncident.js b/models/unlockedIncident.js new file mode 100644 index 0000000..89c8c34 --- /dev/null +++ b/models/unlockedIncident.js @@ -0,0 +1,29 @@ +'use strict'; + +const { unlockedIncidentLevelsPrices } = require('../constants/constants'); + +module.exports = (sequelize, DataTypes) => { + const unlockedIncident = sequelize.define('unlockedIncident', { + reservationId: DataTypes.TEXT, + memberId: DataTypes.TEXT, + resourceId: DataTypes.TEXT, + bookingStart: DataTypes.DATE, + bookingEnd: DataTypes.DATE, + incidentLevel: { + type: DataTypes.ENUM, + values: [ + unlockedIncidentLevelsPrices.UNLOCKED_0.title, + unlockedIncidentLevelsPrices.UNLOCKED_1.title, + unlockedIncidentLevelsPrices.UNLOCKED_2.title, + unlockedIncidentLevelsPrices.UNLOCKED_3.title, + unlockedIncidentLevelsPrices.UNLOCKED_4.title, + unlockedIncidentLevelsPrices.UNLOCKED_5.title, + ] + }, + incidentLevelPrice: DataTypes.FLOAT, + }, {}); + unlockedIncident.associate = function(models) { + // associations can be defined here + }; + return unlockedIncident; +}; diff --git a/models/doorLockIncident.js b/models/unscheduledIncident.js similarity index 55% rename from models/doorLockIncident.js rename to models/unscheduledIncident.js index 4a6e3bb..1f781b9 100644 --- a/models/doorLockIncident.js +++ b/models/unscheduledIncident.js @@ -1,9 +1,9 @@ 'use strict'; -const { doorLockEvents, doorChargeTypes } = require('../constants/constants'); +const { doorLockEvents } = require('../constants/constants'); module.exports = (sequelize, DataTypes) => { - const doorLockIncident = sequelize.define('doorLockIncident', { + const unscheduledIncident = sequelize.define('unscheduledIncident', { reservationId: DataTypes.TEXT, memberId: DataTypes.TEXT, resourceId: DataTypes.TEXT, @@ -14,14 +14,12 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.ENUM, values: [doorLockEvents.USER_LOCKED, doorLockEvents.USER_UNLOCKED] }, - chargeType: { - type: DataTypes.ENUM, - values: [doorChargeTypes.LEFT_UNLOCKED, doorChargeTypes.UNSCHEDULED_USE] - }, - chargeFee: DataTypes.FLOAT, + chargePrice: DataTypes.FLOAT, + timeIntervalsToCharge: DataTypes.INTEGER, + totalChargeFee: DataTypes.FLOAT, }, {}); - doorLockIncident.associate = function(models) { + unscheduledIncident.associate = function(models) { // associations can be defined here }; - return doorLockIncident; + return unscheduledIncident; }; diff --git a/package.json b/package.json index cbfe4b6..c51ec9a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "install-server": "npm install", "install-client": "cd client && yarn install", "docker-build": "docker build -t simaspace .", - "docker-start": "docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=CrmIntegration --name pg_simaspace -d -p 5432:5432 simaspace", + "docker-start": "docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=CrmIntegration --name pg_simaspace -d -p 5431:5432 simaspace", "docker-stop": "docker stop pg_simaspace", "setup": "npm run install-server && npm run install-client && npm run docker-build && npm run docker-start && sleep 5 && npm run migrate", "migrate": "npx sequelize db:migrate", diff --git a/routes/index.js b/routes/index.js index e787596..6e007a2 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,7 +1,9 @@ 'use strict'; const { apiStatusCheck } = require('../controllers/apiStatusCheck'); -const { uploadDoorLockData, getKnownOfficeResourceMappings, addNewMapping } = require('../controllers/doorLock'); +const { uploadDoorLockData } = require('../controllers/doorLock'); +const { getKnownOfficeResourceMappings, addNewMapping } = require('../controllers/integration'); +const { calculateDoorLockCharges } = require('../services/integration/doorLockCharges'); const express = require('express'); const router = express.Router(); @@ -9,7 +11,10 @@ const router = express.Router(); router.get('/', apiStatusCheck); router.post('/doorLock/upload', uploadDoorLockData); -router.get('/doorLock/mappings', getKnownOfficeResourceMappings); -router.post('/doorLock/mappings', addNewMapping); +router.get('/integration/mappings', getKnownOfficeResourceMappings); +router.post('/integration/mappings', addNewMapping); + +// temporary route, manually trigger door lock charge calculations +router.get('/calculate', (req, res) => { calculateDoorLockCharges(); res.send();}); module.exports = router; diff --git a/services/doorLock.js b/services/doorLock/doorLock.js similarity index 82% rename from services/doorLock.js rename to services/doorLock/doorLock.js index 38e7a13..233df5d 100644 --- a/services/doorLock.js +++ b/services/doorLock/doorLock.js @@ -1,9 +1,10 @@ 'use strict'; -const db = require('../models/index'); +const db = require('../../models'); const fs = require('fs'); const csv = require('csv-parser'); -const moment = require('moment'); +const moment = require('moment/moment'); +const Op = require('sequelize').Op; const { USER_ENTRY_EVENT, @@ -12,10 +13,10 @@ const { VALID_CSV_HEADERS, doorLockEvents, csvParserErrors, -} = require('../constants/constants'); +} = require('../../constants/constants'); -const { fetchAllMembers, findMember } = require('../services/officeRnD/members'); -const { getMappingsFromDatabase } = require('../services/officeRnD/resources'); +const { fetchAllMembers, findMember } = require('../officeRnD/members'); +const { getMappingsFromDatabase } = require('../officeRnD/resources'); const extractMappingFromFileName = (fileName) => { const contentBetweenBracketsRegex = /\[(.*?)\]/; @@ -170,13 +171,60 @@ const parseDoorLockDataFile = (file) => { }; const writeDoorLockEvent = (entry) => { - console.log('Write entry : '); - console.log(entry); - console.log('===='); return db.doorLockEvent.findOrCreate({where: {...entry}, defaults: {...entry}}); }; +const getUnlockEntryForReservation = (reservation) => { + const { start, end, memberId, resourceId } = reservation; + + const attributes = ['memberName', 'event', 'timestamp']; + const earliestUnlock = parseInt(process.env.EARLIEST_UNLOCK) || 0; + const fromTimestamp = moment(start).subtract(earliestUnlock).toISOString(); + const toTimestamp = end; + + const filters = { + memberId, + timestamp: { + [Op.and]: [ + {[Op.gt]: fromTimestamp}, + {[Op.lte]: toTimestamp} + ] + }, + event: doorLockEvents.USER_UNLOCKED, + resourceId, + }; + + return db.doorLockEvent.findOne({ + attributes, + where: filters, + }) + +}; + +const getRelatedDoorLockEntries = (fromTimestamp, toTimestamp, memberId, resourceId) => { + const attributes = ['memberName', 'event', 'timestamp']; + + const filters = { + memberId, + timestamp: { + [Op.and]: [ + {[Op.gt]: fromTimestamp}, + {[Op.lte]: toTimestamp} + ] + }, + event: doorLockEvents.USER_LOCKED, + resourceId, + }; + + return db.doorLockEvent.findOne({ + attributes, + where: filters + }) +}; + module.exports = { parseDoorLockDataFile, writeDoorLockEvent, + getRelatedDoorLockEntries, + getUnlockEntryForReservation, }; diff --git a/services/integration/doorLockCharges.js b/services/integration/doorLockCharges.js new file mode 100644 index 0000000..e5a0545 --- /dev/null +++ b/services/integration/doorLockCharges.js @@ -0,0 +1,502 @@ +'use strict'; + +const moment = require('moment-timezone'); +const db = require('../../models/index'); + +const { incidentType, unlockedIncidentLevelsPrices } = require('../../constants/constants'); +const { getUnlockEntryForReservation, getRelatedDoorLockEntries } = require('../doorLock/doorLock'); +const { getFirstPreviousBooking, getFirstNextBooking, getAllFinishedBookings } = require('../officeRnD/bookings'); + +const getSortedIncidentsForMember = (memberId) => { + const attributes = ['bookingStart', 'incidentLevel', 'incidentLevelPrice']; + const filters = { + memberId + }; + const order = [['bookingStart', 'DESC']]; + + return db.unlockedIncident.findAll({ + attributes, + where: filters, + order, + }) +}; + +const createUnlockedIncident = (reservation) => { + return new Promise((resolve, reject) => { + const { reservationId, memberId, resourceId, start, end } = reservation; + + getLastIncidentForMember(memberId) + .then(incidents => { + const lastIncident = incidents && incidents[0] ? incidents[0] : undefined; + + const incident = { + reservationId, + memberId, + resourceId, + bookingStart: start, + bookingEnd: end, + incidentLevel: null, + incidentLevelPrice: null, + }; + + console.log('=> UNLOCKED INCIDENT'); + console.log('\tMember : ', memberId); + console.log('\tStart : ', start); + console.log('\tEnd : ', end); + console.log('\tMore details : '); + + /* + if (lastIncident){ + const lastIncidentLevel = lastIncident.incidentLevel; + const lastIncidentBeginningOfTheMonth = moment(lastIncident.bookingStart).startOf('month'); + const beginningOfTheMonth = moment.utc().startOf('month'); + + const timePassedFromLastIncident = Math.abs(beginningOfTheMonth.diff(lastIncidentBeginningOfTheMonth, 'months')); + + if (timePassedFromLastIncident >= 6){ + console.log('\t\t-> This is first incident for this member in last 6 months'); + incident.incidentLevel = unlockedIncidentLevelsPrices.UNLOCKED_0.title; + incident.incidentLevelPrice = unlockedIncidentLevelsPrices.UNLOCKED_0.price; + } else { + console.log('\t\t-> This member had incident(s) in past 6 months !!!'); + incident.incidentLevel = lastIncidentLevel; + incident.incidentLevelPrice = unlockedIncidentLevelsPrices[lastIncidentLevel].price; + } + console.log('\t\tLast incident details : '); + console.log('\t\tStart : ', lastIncident.bookingStart); + console.log('\t\tCalculated diff : ', timePassedFromLastIncident); + console.log('\t\t------------------'); + console.log('\tNew incident level : ', incident.incidentLevel); + } else { + console.log('\t\tThis is first incident for this member, EVER !'); + incident.incidentLevel = unlockedIncidentLevelsPrices.UNLOCKED_0.title; + incident.incidentLevelPrice = unlockedIncidentLevelsPrices.UNLOCKED_0.price; + } + */ + + db.unlockedIncident.findOrCreate({ + where: { + reservationId, + memberId, + resourceId, + bookingStart: start, + bookingEnd: end, + }, + defaults: { + ...incident + } + }) + .then(()=>resolve()) + .catch((error)=>reject(error)); + }) + .catch((error) => { + reject(error); + }); + }); +}; + +const createUnscheduledUseIncident = (reservation, doorLockEntry) => { + return new Promise((resolve, reject) => { + const timeResolution = parseInt(process.env.UNSCHEDULED_USE_TIME_RESOLUTION); + const chargePrice = parseFloat(process.env.UNSCHEDULED_USE_CHARGE_FEE); + + const reservationEndTime = moment(reservation.end); + const lockedTime = moment(doorLockEntry.timestamp); + const timeDifference = Math.abs(reservationEndTime.diff(lockedTime, 'minutes')); + + const timeIntervalsToCharge = Math.floor(timeDifference / timeResolution); + const totalChargeFee = timeIntervalsToCharge * chargePrice; + + if (timeIntervalsToCharge > 0){ + const incident = { + reservationId: reservation.reservationId, + memberId: reservation.memberId, + resourceId: reservation.resourceId, + bookingStart: reservation.start, + bookingEnd: reservation.end, + doorLockEventTimestamp: doorLockEntry.timestamp, + doorLockEventType: doorLockEntry.event, + chargePrice, + timeIntervalsToCharge, + totalChargeFee, + }; + + db.unscheduledIncident.findOrCreate({where: {...incident}, defaults: {...incident}}) + .then(()=>resolve()) + .catch((error)=>reject(error)); + }else{ + resolve(); + } + }); +}; + +const createDoorLockIncident = (reservation, doorLockEntry) => { + return new Promise((resolve, reject) => { + if (!doorLockEntry){ + // Check if there is unlock entry for this reservation + getUnlockEntryForReservation(reservation) + .then((unlockEntry) => { + if (!unlockEntry){ + // check if there is back-to-back booking before current one + getFirstPreviousBooking(reservation) + .then((previousReservation) => { + if (previousReservation){ + const previousReservationEnd = moment(previousReservation.end); + const currentReservationStart = moment(reservation.start); + const timeDifference = Math.abs(currentReservationStart.diff(previousReservationEnd, 'minutes')); + + const maxBackToBackDifference = parseInt(process.env.MAX_BACK_TO_BACK_DIFFERENCE) || 0; + if (timeDifference <= maxBackToBackDifference) { + createUnlockedIncident(reservation) + .then(() => resolve()) + .catch((error) => reject(error)); + }else{ + resolve(); + } + }else{ + resolve(); + } + }) + .catch((error)=>reject(error)); + }else { + createUnlockedIncident(reservation) + .then(()=>resolve()) + .catch((error)=>reject(error)); + } + }) + .catch((error) => { + reject(error); + }); + }else{ + createUnscheduledUseIncident(reservation, doorLockEntry) + .then(()=>resolve()) + .catch((error) => reject(error)); + } + }); +}; + +const insertUnscheduledIncidents = (incidents) => { + const asyncJobs = []; + incidents.forEach((incident) => { + const { reservation, lockEntry, chargePrice, timeIntervalsToCharge, totalChargeFee } = incident; + const { reservationId, memberId, resourceId, start, end } = reservation; + const { timestamp, event } = lockEntry; + + const incidentForDB = { + reservationId, + memberId, + resourceId, + bookingStart: start, + bookingEnd: end, + doorLockEventTimestamp: timestamp, + doorLockEventType: event, + chargePrice, + timeIntervalsToCharge, + totalChargeFee, + }; + + asyncJobs.push(db.unscheduledIncident.findOrCreate({ + where: { + reservationId, + memberId, + resourceId, + bookingStart: start, + bookingEnd: end, + doorLockEventTimestamp: timestamp, + doorLockEventType: event + }, + defaults: {...incidentForDB}, + })); + }); + + return Promise.all(asyncJobs); +}; + +const insertUnlockedIncidents = (incidents) => { + const asyncJobs = []; + incidents.forEach((incident) => { + const { reservationId, memberId, resourceId, bookingStart, bookingEnd } = incident; + + asyncJobs.push(db.unlockedIncident.findOrCreate({ + where: { + reservationId, + memberId, + resourceId, + bookingStart, + bookingEnd, + }, + defaults: {...incident}, + })); + }); + + return Promise.all(asyncJobs); +}; + +const setUnlockedIncidentsLevel = (incidentReservations) => { + return new Promise ((resolve, reject) => { + const sortingFunction = (reservationA, reservationB) => { + const sortCondition = moment.utc(reservationA.start).isBefore(moment.utc(reservationB.start)); + return sortCondition ? -1 : 1; + }; + + incidentReservations.sort(sortingFunction); + + const membersLastIncident = {}; + + incidentReservations.forEach((reservation) => { + membersLastIncident[reservation.memberId] = { + incidentLevel: null, + incidentTimestamp: null, + }; + }); + + const asyncJobs = []; + Object.keys(membersLastIncident).forEach((memberId) => { + asyncJobs.push(getSortedIncidentsForMember(memberId)); + }); + + Promise.all(asyncJobs) + .then((results) => { + results.forEach((result) => { + const lastIncident = result && result[0] ? result[0] : null; + if (lastIncident) { + membersLastIncident[lastIncident.memberId] = { + incidentLevel: lastIncident.incidentLevel, + incidentTimestamp: lastIncident.bookingStart, + } + } + }); + + const incidentsWithLevel = []; + + incidentReservations.forEach((reservation) => { + const memberLastIncident = membersLastIncident[reservation.memberId]; + + const incident = { + reservationId: reservation.reservationId, + memberId: reservation.memberId, + resourceId: reservation.resourceId, + bookingStart: reservation.start, + bookingEnd: reservation.end, + incidentLevel: undefined, + incidentLevelPrice: undefined, + }; + + if (!memberLastIncident.incidentLevel) { + incident.incidentLevel = unlockedIncidentLevelsPrices.UNLOCKED_0.title; + incident.incidentLevelPrice = unlockedIncidentLevelsPrices.UNLOCKED_0.price; + } else { + const lastIncidentTime = moment.utc(memberLastIncident.incidentTimestamp).startOf('month'); + const currentIncidentTime = moment.utc(reservation.start).startOf('month'); + const timeDiff = Math.abs(lastIncidentTime.diff(currentIncidentTime, 'months')); + + if (timeDiff >= (parseInt(process.env.UNLOCK_STREAK_REPAIR_AFTER) || 6)){ + incident.incidentLevel = unlockedIncidentLevelsPrices.UNLOCKED_0.title; + incident.incidentLevelPrice = unlockedIncidentLevelsPrices.UNLOCKED_0.price; + } else { + const lastIncidentLevelId = unlockedIncidentLevelsPrices[memberLastIncident.incidentLevel].id; + const maxId = 5; + + if ((lastIncidentLevelId && (lastIncidentLevelId >= maxId)) || (timeDiff === 0)){ + incident.incidentLevel = memberLastIncident.incidentLevel; + incident.incidentLevelPrice = unlockedIncidentLevelsPrices[incident.incidentLevel].price; + } else { + const nextId = lastIncidentLevelId + 1; + Object.keys(unlockedIncidentLevelsPrices).forEach((key) => { + if (unlockedIncidentLevelsPrices[key].id === nextId){ + incident.incidentLevel = unlockedIncidentLevelsPrices[key].title; + incident.incidentLevelPrice = unlockedIncidentLevelsPrices[key].price + } + }); + } + } + } + memberLastIncident.incidentLevel = incident.incidentLevel; + memberLastIncident.incidentTimestamp = incident.bookingStart; + + incidentsWithLevel.push(incident); + }); + + resolve(incidentsWithLevel); + }) + .catch((error) => reject(error)); + }); +}; + +const getIncidentData = (reservation) => { + return new Promise ((resolve, reject) => { + getFirstNextBooking(reservation) + .then(nextReservation => { + const endOfTheDay = moment.tz(reservation.end, reservation.timezone).endOf('Day').toISOString(); + let doorLockEntriesEndTime = nextReservation ? nextReservation.start : endOfTheDay; + + if (nextReservation){ + // Check if next reservations is immediately after (back to back reservation) + // If yes, then there is no need to check door lock entries related to this booking + const firstReservationEnd = moment(reservation.end); + const secondReservationStart = moment(nextReservation.start); + const timeDifference = Math.abs(secondReservationStart.diff(firstReservationEnd, 'minutes')); + + const maxBackToBackDifference = parseInt(process.env.MAX_BACK_TO_BACK_DIFFERENCE) || 0; + if (timeDifference <= maxBackToBackDifference){ + // It is back to back reservation, no need to check door lock entries + resolve({ + incidentType: incidentType.NOT_AN_INCIDENT, + }); + return; + } + } + // Find door lock entries related to this member, between booking start time and + // next booking start time OR end of the day + + getRelatedDoorLockEntries(reservation.start, doorLockEntriesEndTime, reservation.memberId, reservation.resourceId) + .then((lockEntry) => { + if (lockEntry){ + const timeResolution = parseInt(process.env.UNSCHEDULED_USE_TIME_RESOLUTION); + const chargePrice = parseFloat(process.env.UNSCHEDULED_USE_CHARGE_FEE); + + const reservationEndTime = moment(reservation.end); + const lockedTime = moment(lockEntry.timestamp); + const timeDifference = Math.abs(reservationEndTime.diff(lockedTime, 'minutes')); + + const timeIntervalsToCharge = Math.floor(timeDifference / timeResolution); + const totalChargeFee = timeIntervalsToCharge * chargePrice; + + if (timeIntervalsToCharge > 0){ + resolve({ + incidentType: incidentType.UNSCHEDULED_INCIDENT, + reservation, + lockEntry, + chargePrice, + timeIntervalsToCharge, + totalChargeFee, + }) + } else { + resolve({ + incidentType: incidentType.NOT_AN_INCIDENT, + }); + } + } else { + // Check if there is unlock entry for this reservation + getUnlockEntryForReservation(reservation) + .then((unlockEntry) => { + if (unlockEntry){ + // This is unlocked incident + resolve({ + incidentType: incidentType.UNLOCKED_INCIDENT, + reservation, + }); + }else{ + // Check if there is back-to-back booking before current one + getFirstPreviousBooking(reservation) + .then((previousReservation) => { + if (previousReservation){ + const previousReservationEnd = moment(previousReservation.end); + const currentReservationStart = moment(reservation.start); + const timeDifference = Math.abs(currentReservationStart.diff(previousReservationEnd, 'minutes')); + + const maxBackToBackDifference = parseInt(process.env.MAX_BACK_TO_BACK_DIFFERENCE) || 0; + if (timeDifference <= maxBackToBackDifference) { + resolve({ + incidentType: incidentType.UNLOCKED_INCIDENT, + reservation, + }); + }else{ + resolve({ + incidentType: incidentType.NOT_AN_INCIDENT, + }); + } + }else{ + resolve({ + incidentType: incidentType.NOT_AN_INCIDENT, + }); + } + }) + .catch((error) => { + console.log('Error finding first previous reservation', error); + resolve({ + error, + }); + }); + } + }) + .catch((error) => { + console.log('Error finding unlock entry', error); + resolve({ + error + }); + }); + } + }) + .catch((error) => { + console.log('Error finding related door lock entry', error); + resolve({ + error, + }); + }); + + }) + .catch((error) => { + console.log('Error finding first next booking', error); + resolve({ + error, + }); + }); + }); +}; + +const calculateDoorLockCharges = () => { + getAllFinishedBookings() + .then((reservations) => { + const unlockedIncidents = []; + const unscheduledIncidents = []; + + const asyncCheckForIncidents = []; + + reservations.forEach((reservation) => { + asyncCheckForIncidents.push(getIncidentData(reservation)); + }); + + Promise.all(asyncCheckForIncidents) + .then((results) => { + results.forEach((result) => { + if (result.error){ + console.log('Error checking incident : ', result.error); + }else if(result.incidentType) { + switch (result.incidentType) { + case incidentType.UNLOCKED_INCIDENT: + unlockedIncidents.push(result.reservation); + break; + case incidentType.UNSCHEDULED_INCIDENT: + const { reservation, lockEntry, chargePrice, timeIntervalsToCharge, totalChargeFee } = result; + unscheduledIncidents.push({ + reservation, + lockEntry, + chargePrice, + timeIntervalsToCharge, + totalChargeFee, + }); + break; + } + } + + }); + + insertUnscheduledIncidents(unscheduledIncidents) + .catch((error) => console.log(error)); + + setUnlockedIncidentsLevel(unlockedIncidents) + .then((completedUnlockedIncidents) => { + insertUnlockedIncidents(completedUnlockedIncidents) + .catch((error) => console.log(error)); + }) + .catch((error) => console.log(error)); + }) + .catch((error) => console.log(error)); + }) + .catch((error) => console.log(error)); +}; + +module.exports = { + calculateDoorLockCharges +}; diff --git a/services/officeRnD/bookings.js b/services/officeRnD/bookings.js index 7fe57e8..e63d3d3 100644 --- a/services/officeRnD/bookings.js +++ b/services/officeRnD/bookings.js @@ -1,8 +1,11 @@ 'use strict'; const db = require('../../models/index'); +const moment = require('moment-timezone'); +const Op = require('sequelize').Op; const { API } = require('../../helpers/api'); +const { officeRnDAPIErrors } = require('../../constants/constants'); const fetchAllBookings = () => { return new Promise((resolve, reject) => { @@ -15,19 +18,110 @@ const fetchAllBookings = () => { cleanedBookingReservations.push({ reservationId: fullBookingEntry['_id'], memberId: fullBookingEntry.member, + officeId: fullBookingEntry.office, resourceId: fullBookingEntry.resourceId, start: fullBookingEntry.start.dateTime, end: fullBookingEntry.end.dateTime, + timezone: fullBookingEntry.timezone, + canceled: fullBookingEntry.canceled || false, }); }); resolve(cleanedBookingReservations); }) .catch((error) => { - reject(error); + console.log(officeRnDAPIErrors.FAILED_TO_FETCH_BOOKINGS); + console.log('Details : ', error); + reject(officeRnDAPIErrors.FAILED_TO_FETCH_BOOKINGS); }); }); }; +const getAllFinishedBookings = () => { + const attributes = ['reservationId', 'memberId', 'resourceId', 'start', 'end', 'timezone']; + const filters = { + canceled: false, + end: { + [Op.lt]: moment().toISOString() + } + }; + + return db.bookingReservation.findAll({ + attributes, + where: filters, + order: [ + ['start', 'ASC'], + ] + }) +}; + +const getFirstNextBooking = (reservation) => { + return new Promise ((resolve, reject) => { + const {resourceId, start, timezone} = reservation; + const endOfTheDay = moment.tz(start, timezone).endOf('Day').toISOString(); + + const attributes = ['reservationId', 'memberId', 'resourceId', 'start', 'end', 'timezone']; + const filters = { + canceled: false, + start: { + [Op.gt]: start + }, + end: { + [Op.lte]: endOfTheDay + }, + resourceId, + }; + const order = [['start', 'ASC']]; + + db.bookingReservation.findAll({ + attributes, + where: filters, + order, + }) + .then((reservations) => { + if (reservations && reservations[0]){ + resolve(reservations[0]); + }else{ + resolve(undefined); + } + }) + .catch((error) => reject(error)); + }); +}; + +const getFirstPreviousBooking = (reservation) => { + return new Promise ((resolve, reject) => { + const {resourceId, start, timezone} = reservation; + const startOfTheDay = moment.tz(start, timezone).startOf('Day').toISOString(); + + const attributes = ['reservationId', 'memberId', 'resourceId', 'start', 'end', 'timezone']; + const filters = { + canceled: false, + start: { + [Op.gte]: startOfTheDay + }, + end: { + [Op.lte]: start + }, + resourceId, + }; + const order = [['end', 'DESC']]; + + db.bookingReservation.findAll({ + attributes, + where: filters, + order, + }) + .then((reservations) => { + if (reservations && reservations[0]){ + resolve(reservations[0]); + }else{ + resolve(undefined); + } + }) + .catch((error) => reject(error)); + }); +}; + const writeBookingReservation = (bookingReservation) => { return db.bookingReservation.findOrCreate({where: {...bookingReservation}, defaults: {...bookingReservation}}); }; @@ -35,4 +129,7 @@ const writeBookingReservation = (bookingReservation) => { module.exports = { fetchAllBookings, writeBookingReservation, + getAllFinishedBookings, + getFirstNextBooking, + getFirstPreviousBooking, };